feat(security): add database audit logging and asynchronous markdown email alerts for api client actions
This commit is contained in:
parent
709568f1ed
commit
61073f4fcd
15 changed files with 664 additions and 1 deletions
|
|
@ -73,6 +73,7 @@ MAIL_USERNAME=null
|
|||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
SECURITY_ALERT_EMAIL="security@example.com"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ BROADCAST_CONNECTION=null
|
|||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=sync
|
||||
CACHE_STORE=array
|
||||
MAIL_MAILER=
|
||||
MAIL_MAILER=array
|
||||
|
||||
ARCADIA_CACHE_STORE=array
|
||||
|
||||
|
|
|
|||
|
|
@ -117,4 +117,15 @@ return [
|
|||
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Security Alerts Recipient
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The email address that receives administrative and critical security alerts.
|
||||
|
|
||||
*/
|
||||
|
||||
'security_recipient' => env('SECURITY_ALERT_EMAIL', 'security@example.com'),
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class () extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('client_audit_logs', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->uuid('client_id')->index();
|
||||
$table->string('client_identifier');
|
||||
$table->string('event_type');
|
||||
$table->json('details')->nullable();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('client_audit_logs');
|
||||
}
|
||||
};
|
||||
50
src/Infra/Database/Models/ClientAuditLog.php
Normal file
50
src/Infra/Database/Models/ClientAuditLog.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Database\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Infra\Casts\UuidCast;
|
||||
|
||||
/**
|
||||
* @property \Ramsey\Uuid\UuidInterface $id
|
||||
* @property \Ramsey\Uuid\UuidInterface $client_id
|
||||
* @property string $client_identifier
|
||||
* @property string $event_type
|
||||
* @property array<string, mixed>|null $details
|
||||
* @property \DateTimeInterface $created_at
|
||||
*
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
final class ClientAuditLog extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'client_audit_logs';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
/** @var list<string> */
|
||||
protected $fillable = [
|
||||
'id',
|
||||
'client_id',
|
||||
'client_identifier',
|
||||
'event_type',
|
||||
'details',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
/** @return array<string, string> */
|
||||
#[\Override]
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'id' => UuidCast::class,
|
||||
'client_id' => UuidCast::class,
|
||||
'details' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
45
src/Infra/Listeners/RecordClientSecurityAudit.php
Normal file
45
src/Infra/Listeners/RecordClientSecurityAudit.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Listeners;
|
||||
|
||||
use Application\Security\Events\ClientSecretGenerated;
|
||||
use Application\Security\Events\ClientSecretRegenerated;
|
||||
use Application\Security\Events\ClientStatusToggled;
|
||||
use Infra\Database\Models\ClientAuditLog;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
final readonly class RecordClientSecurityAudit
|
||||
{
|
||||
public function handle(ClientSecretGenerated | ClientSecretRegenerated | ClientStatusToggled $event): void
|
||||
{
|
||||
$eventType = match (true) {
|
||||
$event instanceof ClientSecretGenerated => 'client_secret_generated',
|
||||
$event instanceof ClientSecretRegenerated => 'client_secret_regenerated',
|
||||
$event instanceof ClientStatusToggled => 'client_status_toggled',
|
||||
};
|
||||
|
||||
$details = match (true) {
|
||||
$event instanceof ClientStatusToggled => [
|
||||
'is_active' => $event->isActive,
|
||||
],
|
||||
default => null,
|
||||
};
|
||||
|
||||
$occurredAt = match (true) {
|
||||
$event instanceof ClientSecretGenerated => $event->generatedAt,
|
||||
$event instanceof ClientSecretRegenerated => $event->generatedAt,
|
||||
$event instanceof ClientStatusToggled => $event->toggledAt,
|
||||
};
|
||||
|
||||
ClientAuditLog::query()->create([
|
||||
'id' => Uuid::uuid7()->toString(),
|
||||
'client_id' => $event->clientId->value,
|
||||
'client_identifier' => $event->clientIdentifier->value,
|
||||
'event_type' => $eventType,
|
||||
'details' => $details,
|
||||
'created_at' => $occurredAt,
|
||||
]);
|
||||
}
|
||||
}
|
||||
84
src/Infra/Listeners/SendClientSecurityAlertMail.php
Normal file
84
src/Infra/Listeners/SendClientSecurityAlertMail.php
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Listeners;
|
||||
|
||||
use Application\Security\Events\ClientSecretGenerated;
|
||||
use Application\Security\Events\ClientSecretRegenerated;
|
||||
use Application\Security\Events\ClientStatusToggled;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Config\Repository as ConfigRepository;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailables\Address;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Infra\Mail\ClientSecurityAlertMail;
|
||||
use Infra\Mail\SecurityAlertDetails;
|
||||
|
||||
final class SendClientSecurityAlertMail implements ShouldQueue
|
||||
{
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly Mailer $mailer,
|
||||
private readonly ConfigRepository $config,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(ClientSecretGenerated | ClientSecretRegenerated | ClientStatusToggled $event): void
|
||||
{
|
||||
$recipient = $this->getEventRecipient();
|
||||
$eventType = $this->getEventType($event);
|
||||
$details = $this->getEventDetails($event);
|
||||
$occurredAt = $this->getEventOccurredAt($event);
|
||||
|
||||
$mailable = new ClientSecurityAlertMail(
|
||||
eventType: $eventType,
|
||||
clientId: $event->clientId->identifier,
|
||||
clientIdentifier: $event->clientIdentifier->value,
|
||||
occurredAt: $occurredAt,
|
||||
details: $details,
|
||||
);
|
||||
|
||||
$this->mailer->to($recipient)
|
||||
->send($mailable);
|
||||
}
|
||||
|
||||
private function getEventRecipient(): Address
|
||||
{
|
||||
$recipientValue = $this->config->get('mail.security_recipient', 'security@example.com');
|
||||
|
||||
return new Address(is_string($recipientValue) ? $recipientValue : 'security@example.com');
|
||||
}
|
||||
|
||||
private function getEventType(ClientSecretGenerated | ClientSecretRegenerated | ClientStatusToggled $event): string
|
||||
{
|
||||
return match (true) {
|
||||
$event instanceof ClientSecretGenerated => 'Client Secret Generated',
|
||||
$event instanceof ClientSecretRegenerated => 'Client Secret Regenerated',
|
||||
$event instanceof ClientStatusToggled => sprintf(
|
||||
'Client Status Changed (%s)',
|
||||
$event->isActive ? 'Activated' : 'Deactivated'
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private function getEventDetails(ClientSecretGenerated | ClientSecretRegenerated | ClientStatusToggled $event): SecurityAlertDetails
|
||||
{
|
||||
return match (true) {
|
||||
$event instanceof ClientStatusToggled => SecurityAlertDetails::forStatusToggled($event->isActive),
|
||||
default => SecurityAlertDetails::empty(),
|
||||
};
|
||||
}
|
||||
|
||||
private function getEventOccurredAt(ClientSecretGenerated | ClientSecretRegenerated | ClientStatusToggled $event): \DateTimeInterface
|
||||
{
|
||||
return match (true) {
|
||||
$event instanceof ClientSecretGenerated => $event->generatedAt,
|
||||
$event instanceof ClientSecretRegenerated => $event->generatedAt,
|
||||
$event instanceof ClientStatusToggled => $event->toggledAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
53
src/Infra/Mail/ClientSecurityAlertMail.php
Normal file
53
src/Infra/Mail/ClientSecurityAlertMail.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
final class ClientSecurityAlertMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $eventType,
|
||||
public readonly UuidInterface $clientId,
|
||||
public readonly string $clientIdentifier,
|
||||
public readonly \DateTimeInterface $occurredAt,
|
||||
public readonly SecurityAlertDetails $details,
|
||||
) {
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: sprintf(
|
||||
'[Arcadia Security] %s for %s',
|
||||
$this->eventType,
|
||||
$this->clientIdentifier
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'mails.security-alert',
|
||||
with: [
|
||||
'eventType' => $this->eventType,
|
||||
'clientId' => $this->clientId,
|
||||
'clientIdentifier' => $this->clientIdentifier,
|
||||
'occurredAt' => $this->occurredAt,
|
||||
'details' => $this->details,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
27
src/Infra/Mail/SecurityAlertDetails.php
Normal file
27
src/Infra/Mail/SecurityAlertDetails.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Infra\Mail;
|
||||
|
||||
final readonly class SecurityAlertDetails
|
||||
{
|
||||
public function __construct(
|
||||
public ?bool $isActive = null,
|
||||
public ?string $description = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function forStatusToggled(bool $isActive): self
|
||||
{
|
||||
return new self(
|
||||
isActive: $isActive,
|
||||
description: $isActive ? 'Client account activated' : 'Client account suspended',
|
||||
);
|
||||
}
|
||||
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@ use Infra\Listeners\LogBrandCreated;
|
|||
use Infra\Listeners\LogBrandRenamed;
|
||||
use Infra\Listeners\LogClientSecretRotated;
|
||||
use Infra\Listeners\LogClientStatusChanged;
|
||||
use Infra\Listeners\RecordClientSecurityAudit;
|
||||
use Infra\Listeners\SendClientSecurityAlertMail;
|
||||
|
||||
class EventServiceProvider extends ServiceProvider
|
||||
{
|
||||
|
|
@ -46,14 +48,44 @@ class EventServiceProvider extends ServiceProvider
|
|||
LogClientSecretRotated::class,
|
||||
);
|
||||
|
||||
Event::listen(
|
||||
ClientSecretGenerated::class,
|
||||
RecordClientSecurityAudit::class,
|
||||
);
|
||||
|
||||
Event::listen(
|
||||
ClientSecretGenerated::class,
|
||||
SendClientSecurityAlertMail::class,
|
||||
);
|
||||
|
||||
Event::listen(
|
||||
ClientSecretRegenerated::class,
|
||||
LogClientSecretRotated::class,
|
||||
);
|
||||
|
||||
Event::listen(
|
||||
ClientSecretRegenerated::class,
|
||||
RecordClientSecurityAudit::class,
|
||||
);
|
||||
|
||||
Event::listen(
|
||||
ClientSecretRegenerated::class,
|
||||
SendClientSecurityAlertMail::class,
|
||||
);
|
||||
|
||||
Event::listen(
|
||||
ClientStatusToggled::class,
|
||||
LogClientStatusChanged::class,
|
||||
);
|
||||
|
||||
Event::listen(
|
||||
ClientStatusToggled::class,
|
||||
RecordClientSecurityAudit::class,
|
||||
);
|
||||
|
||||
Event::listen(
|
||||
ClientStatusToggled::class,
|
||||
SendClientSecurityAlertMail::class,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
<x-mail::message>
|
||||
# ⚠️ Alerte de Sécurité - Arcadia API
|
||||
|
||||
Une action d'écriture sensible a été effectuée sur un client API.
|
||||
|
||||
<x-mail::panel>
|
||||
**Événement :** {{ $eventType }}
|
||||
**Identifiant Client :** `{{ $clientIdentifier }}`
|
||||
**Client ID :** `{{ $clientId->toString() }}`
|
||||
**Date & Heure :** {{ $occurredAt->format('d/m/Y H:i:s') }}
|
||||
@if ($details->isActive !== null)
|
||||
**Statut du Client :** {{ $details->isActive ? '🟢 Actif' : '🔴 Inactif' }}
|
||||
@endif
|
||||
@if ($details->description !== null)
|
||||
**Détail :**
|
||||
<pre>{{ $details->description }}</pre>
|
||||
@endif
|
||||
</x-mail::panel>
|
||||
|
||||
Ce message est généré automatiquement par le système Arcadia API.
|
||||
</x-mail::message>
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration\Infra\Listeners;
|
||||
|
||||
use Application\Security\Events\ClientSecretGenerated;
|
||||
use Application\Security\Events\ClientSecretRegenerated;
|
||||
use Application\Security\Events\ClientStatusToggled;
|
||||
use Application\Security\Models\ValueObjects\ClientIdentifier;
|
||||
use Application\Security\Models\ValueObjects\Entities\ClientId;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Infra\Database\Models\ClientAuditLog;
|
||||
use Infra\Listeners\RecordClientSecurityAudit;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
covers(RecordClientSecurityAudit::class);
|
||||
|
||||
describe('RecordClientSecurityAudit Listener Integration Tests', function (): void {
|
||||
beforeEach(function (): void {
|
||||
ClientAuditLog::query()->delete();
|
||||
});
|
||||
|
||||
it('records audit log when ClientSecretGenerated event is handled', function (): void {
|
||||
// GIVEN
|
||||
$clientId = new ClientId(Uuid::uuid7());
|
||||
$clientIdentifier = new ClientIdentifier('arcadia_id_11111111111111111111111111111111');
|
||||
$generatedAt = new \DateTimeImmutable('2026-08-21 10:00:00');
|
||||
|
||||
$event = new ClientSecretGenerated(
|
||||
clientId: $clientId,
|
||||
clientIdentifier: $clientIdentifier,
|
||||
generatedAt: $generatedAt,
|
||||
);
|
||||
|
||||
$listener = new RecordClientSecurityAudit();
|
||||
|
||||
// WHEN
|
||||
$listener->handle($event);
|
||||
|
||||
// THEN
|
||||
$log = ClientAuditLog::query()->where('client_id', $clientId->value)->first();
|
||||
expect($log)->not->toBeNull()
|
||||
->and($log->id)->toBeInstanceOf(UuidInterface::class)
|
||||
->and($log->client_id)->toBeInstanceOf(UuidInterface::class)
|
||||
->and($log->client_id->toString())->toBe($clientId->value)
|
||||
->and($log->client_identifier)->toBe($clientIdentifier->value)
|
||||
->and($log->event_type)->toBe('client_secret_generated')
|
||||
->and($log->details)->toBeNull()
|
||||
->and($log->created_at->format('Y-m-d H:i:s'))->toBe('2026-08-21 10:00:00');
|
||||
});
|
||||
|
||||
it('records audit log when ClientSecretRegenerated event is handled', function (): void {
|
||||
// GIVEN
|
||||
$clientId = new ClientId(Uuid::uuid7());
|
||||
$clientIdentifier = new ClientIdentifier('arcadia_id_22222222222222222222222222222222');
|
||||
$regeneratedAt = new \DateTimeImmutable('2026-08-21 11:30:00');
|
||||
|
||||
$event = new ClientSecretRegenerated(
|
||||
clientId: $clientId,
|
||||
clientIdentifier: $clientIdentifier,
|
||||
generatedAt: $regeneratedAt,
|
||||
);
|
||||
|
||||
$listener = new RecordClientSecurityAudit();
|
||||
|
||||
// WHEN
|
||||
$listener->handle($event);
|
||||
|
||||
// THEN
|
||||
$log = ClientAuditLog::query()->where('client_id', $clientId->value)->first();
|
||||
expect($log)->not->toBeNull()
|
||||
->and($log->client_identifier)->toBe($clientIdentifier->value)
|
||||
->and($log->event_type)->toBe('client_secret_regenerated')
|
||||
->and($log->details)->toBeNull()
|
||||
->and($log->created_at->format('Y-m-d H:i:s'))->toBe('2026-08-21 11:30:00');
|
||||
});
|
||||
|
||||
it('records audit log when ClientStatusToggled event is handled', function (): void {
|
||||
// GIVEN
|
||||
$clientId = new ClientId(Uuid::uuid7());
|
||||
$clientIdentifier = new ClientIdentifier('arcadia_id_33333333333333333333333333333333');
|
||||
$toggledAt = new \DateTimeImmutable('2026-08-21 12:00:00');
|
||||
|
||||
$event = new ClientStatusToggled(
|
||||
clientId: $clientId,
|
||||
clientIdentifier: $clientIdentifier,
|
||||
isActive: false,
|
||||
toggledAt: $toggledAt,
|
||||
);
|
||||
|
||||
$listener = new RecordClientSecurityAudit();
|
||||
|
||||
// WHEN
|
||||
$listener->handle($event);
|
||||
|
||||
// THEN
|
||||
$log = ClientAuditLog::query()->where('client_id', $clientId->value)->first();
|
||||
expect($log)->not->toBeNull()
|
||||
->and($log->client_identifier)->toBe($clientIdentifier->value)
|
||||
->and($log->event_type)->toBe('client_status_toggled')
|
||||
->and($log->details)->toBe(['is_active' => false])
|
||||
->and($log->created_at->format('Y-m-d H:i:s'))->toBe('2026-08-21 12:00:00');
|
||||
});
|
||||
});
|
||||
103
tests/Unit/Infra/Listeners/SendClientSecurityAlertMailTest.php
Normal file
103
tests/Unit/Infra/Listeners/SendClientSecurityAlertMailTest.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Infra\Listeners;
|
||||
|
||||
use Application\Security\Events\ClientSecretGenerated;
|
||||
use Application\Security\Events\ClientSecretRegenerated;
|
||||
use Application\Security\Events\ClientStatusToggled;
|
||||
use Application\Security\Models\ValueObjects\ClientIdentifier;
|
||||
use Application\Security\Models\ValueObjects\Entities\ClientId;
|
||||
use Illuminate\Contracts\Config\Repository as ConfigRepository;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Infra\Listeners\SendClientSecurityAlertMail;
|
||||
use Infra\Mail\ClientSecurityAlertMail;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
covers(SendClientSecurityAlertMail::class);
|
||||
|
||||
describe('SendClientSecurityAlertMail Listener Unit Tests', function (): void {
|
||||
beforeEach(function (): void {
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
it('sends email alert when ClientSecretGenerated is handled', function (): void {
|
||||
// GIVEN
|
||||
$clientId = new ClientId(Uuid::uuid7());
|
||||
$clientIdentifier = new ClientIdentifier('arcadia_id_11111111111111111111111111111111');
|
||||
$generatedAt = new \DateTimeImmutable('2026-08-21 10:00:00');
|
||||
|
||||
$event = new ClientSecretGenerated(
|
||||
clientId: $clientId,
|
||||
clientIdentifier: $clientIdentifier,
|
||||
generatedAt: $generatedAt,
|
||||
);
|
||||
|
||||
$configMock = resolve(ConfigRepository::class);
|
||||
$listener = new SendClientSecurityAlertMail(Mail::mailer(), $configMock);
|
||||
|
||||
// WHEN
|
||||
$listener->handle($event);
|
||||
|
||||
// THEN
|
||||
Mail::assertQueued(ClientSecurityAlertMail::class, function (ClientSecurityAlertMail $mail) use ($clientIdentifier): bool {
|
||||
return $mail->eventType === 'Client Secret Generated'
|
||||
&& $mail->clientIdentifier === $clientIdentifier->value;
|
||||
});
|
||||
});
|
||||
|
||||
it('sends email alert when ClientSecretRegenerated is handled', function (): void {
|
||||
// GIVEN
|
||||
$clientId = new ClientId(Uuid::uuid7());
|
||||
$clientIdentifier = new ClientIdentifier('arcadia_id_22222222222222222222222222222222');
|
||||
$regeneratedAt = new \DateTimeImmutable('2026-08-21 11:00:00');
|
||||
|
||||
$event = new ClientSecretRegenerated(
|
||||
clientId: $clientId,
|
||||
clientIdentifier: $clientIdentifier,
|
||||
generatedAt: $regeneratedAt,
|
||||
);
|
||||
|
||||
$configMock = resolve(ConfigRepository::class);
|
||||
$listener = new SendClientSecurityAlertMail(Mail::mailer(), $configMock);
|
||||
|
||||
// WHEN
|
||||
$listener->handle($event);
|
||||
|
||||
// THEN
|
||||
Mail::assertQueued(ClientSecurityAlertMail::class, function (ClientSecurityAlertMail $mail) use ($clientIdentifier): bool {
|
||||
return $mail->eventType === 'Client Secret Regenerated'
|
||||
&& $mail->clientIdentifier === $clientIdentifier->value;
|
||||
});
|
||||
});
|
||||
|
||||
it('sends email alert when ClientStatusToggled is handled', function (): void {
|
||||
// GIVEN
|
||||
$clientId = new ClientId(Uuid::uuid7());
|
||||
$clientIdentifier = new ClientIdentifier('arcadia_id_33333333333333333333333333333333');
|
||||
$toggledAt = new \DateTimeImmutable('2026-08-21 12:00:00');
|
||||
|
||||
$event = new ClientStatusToggled(
|
||||
clientId: $clientId,
|
||||
clientIdentifier: $clientIdentifier,
|
||||
isActive: false,
|
||||
toggledAt: $toggledAt,
|
||||
);
|
||||
|
||||
$configMock = resolve(ConfigRepository::class);
|
||||
$listener = new SendClientSecurityAlertMail(Mail::mailer(), $configMock);
|
||||
|
||||
// WHEN
|
||||
$listener->handle($event);
|
||||
|
||||
// THEN
|
||||
Mail::assertQueued(ClientSecurityAlertMail::class, function (ClientSecurityAlertMail $mail) use ($clientIdentifier): bool {
|
||||
return $mail->eventType === 'Client Status Changed (Deactivated)'
|
||||
&& $mail->clientIdentifier === $clientIdentifier->value
|
||||
&& $mail->details->isActive === false;
|
||||
});
|
||||
});
|
||||
});
|
||||
57
tests/Unit/Infra/Mail/ClientSecurityAlertMailTest.php
Normal file
57
tests/Unit/Infra/Mail/ClientSecurityAlertMailTest.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Infra\Mail;
|
||||
|
||||
use Infra\Mail\ClientSecurityAlertMail;
|
||||
use Infra\Mail\SecurityAlertDetails;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
covers(ClientSecurityAlertMail::class);
|
||||
|
||||
describe('ClientSecurityAlertMail Unit Tests', function (): void {
|
||||
it('builds email envelope with correct subject', function (): void {
|
||||
// GIVEN
|
||||
$mail = new ClientSecurityAlertMail(
|
||||
eventType: 'Client Secret Generated',
|
||||
clientId: Uuid::fromString('01917b2f-7f41-7654-8c88-66280be01234'),
|
||||
clientIdentifier: 'arcadia_id_testclient',
|
||||
occurredAt: new \DateTimeImmutable('2026-08-21 10:00:00 UTC'),
|
||||
details: SecurityAlertDetails::empty(),
|
||||
);
|
||||
|
||||
// WHEN
|
||||
$envelope = $mail->envelope();
|
||||
|
||||
// THEN
|
||||
expect($envelope->subject)->toBe('[Arcadia Security] Client Secret Generated for arcadia_id_testclient');
|
||||
});
|
||||
|
||||
it('builds email content with correct markdown view and typed details', function (): void {
|
||||
// GIVEN
|
||||
$clientId = Uuid::fromString('01917b2f-7f41-7654-8c88-66280be01234');
|
||||
$details = SecurityAlertDetails::forStatusToggled(false);
|
||||
$occuredAt = new \DateTimeImmutable('2026-08-21 10:00:00 UTC');
|
||||
$mail = new ClientSecurityAlertMail(
|
||||
eventType: 'Client Status Changed (Deactivated)',
|
||||
clientId: $clientId,
|
||||
clientIdentifier: 'arcadia_id_testclient',
|
||||
occurredAt: $occuredAt,
|
||||
details: $details,
|
||||
);
|
||||
|
||||
// WHEN
|
||||
$content = $mail->content();
|
||||
|
||||
// THEN
|
||||
expect($content->markdown)->toBe('mails.security-alert')
|
||||
->and($content->with['eventType'])->toBe('Client Status Changed (Deactivated)')
|
||||
->and($content->with['clientIdentifier'])->toBe('arcadia_id_testclient')
|
||||
->and($content->with['clientId'])->toBe($clientId)
|
||||
->and($content->with['occurredAt'])->toBe($occuredAt)
|
||||
->and($content->with['details'])->toBe($details)
|
||||
->and($content->with['details']->isActive)->toBeFalse()
|
||||
->and($content->with['details']->description)->toBe('Client account suspended');
|
||||
});
|
||||
});
|
||||
38
tests/Unit/Infra/Mail/SecurityAlertDetailsTest.php
Normal file
38
tests/Unit/Infra/Mail/SecurityAlertDetailsTest.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Infra\Mail;
|
||||
|
||||
use Infra\Mail\SecurityAlertDetails;
|
||||
|
||||
covers(SecurityAlertDetails::class);
|
||||
|
||||
describe('SecurityAlertDetails DTO Unit Tests', function (): void {
|
||||
it('creates empty details correctly', function (): void {
|
||||
// WHEN
|
||||
$details = SecurityAlertDetails::empty();
|
||||
|
||||
// THEN
|
||||
expect($details->isActive)->toBeNull()
|
||||
->and($details->description)->toBeNull();
|
||||
});
|
||||
|
||||
it('creates details for activated status toggle', function (): void {
|
||||
// WHEN
|
||||
$details = SecurityAlertDetails::forStatusToggled(true);
|
||||
|
||||
// THEN
|
||||
expect($details->isActive)->toBeTrue()
|
||||
->and($details->description)->toBe('Client account activated');
|
||||
});
|
||||
|
||||
it('creates details for deactivated status toggle', function (): void {
|
||||
// WHEN
|
||||
$details = SecurityAlertDetails::forStatusToggled(false);
|
||||
|
||||
// THEN
|
||||
expect($details->isActive)->toBeFalse()
|
||||
->and($details->description)->toBe('Client account suspended');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue