feat(security): add arcadia:security:audit-logs CLI command with CSV export and typed audit details

This commit is contained in:
Jérémy Munsch 2026-08-21 12:55:01 +02:00
parent 61073f4fcd
commit a82a1558bd
23 changed files with 1080 additions and 15 deletions

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Application\Security\Models\Entities;
use Application\Security\Models\ValueObjects\Entities\SecurityAuditLogDetails;
use Application\Security\Models\ValueObjects\Entities\SecurityAuditLogEntryId;
use Ramsey\Uuid\UuidInterface;
use Shared\DDD\Model\Entity;
use Shared\DDD\Model\HasTimestampIdentifier;
/**
* @implements Entity<SecurityAuditLogEntryId>
*/
final readonly class SecurityAuditLogEntry implements Entity, HasTimestampIdentifier
{
public function __construct(
public SecurityAuditLogEntryId $id,
public UuidInterface $clientId,
public string $clientIdentifier,
public string $eventType,
public SecurityAuditLogDetails $details,
) {
}
#[\Override]
public function id(): SecurityAuditLogEntryId
{
return $this->id;
}
#[\Override]
public function createdAt(): \DateTimeInterface
{
return $this->id->toDateTime();
}
}

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Application\Security\Models\ValueObjects\Entities;
use Shared\DDD\Model\Equatable;
final readonly class SecurityAuditLogDetails implements Equatable
{
/**
* @param array<string, string> $values
*/
public function __construct(
private array $values = [],
) {
}
/**
* @return array<string, string>
*/
public function getValues(): array
{
$output = [];
foreach ($this->values as $key => $value) {
$output[(string) $key] = (string) $value;
}
return $output;
}
public static function empty(): self
{
return new self([]);
}
public function isEmpty(): bool
{
return $this->values === [];
}
public function get(string $key): ?string
{
return $this->values[$key] ?? null;
}
#[\Override]
public function equals(mixed $other): bool
{
return $other instanceof self
&& $this->values === $other->values;
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Application\Security\Models\ValueObjects\Entities;
use Ramsey\Uuid\UuidInterface;
use Shared\DDD\Model\EntityId;
use Shared\Helpers\Exceptions\InvalidUuidException;
use Shared\Helpers\UuidHelper;
final readonly class SecurityAuditLogEntryId extends EntityId
{
public function __construct(
public readonly UuidInterface $identifier,
) {
if (UuidHelper::isV7($identifier) === false) {
throw InvalidUuidException::forValue($identifier->toString());
}
parent::__construct($identifier->toString());
}
public function toDateTime(): \DateTimeInterface
{
return UuidHelper::toDateTime($this->identifier);
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Application\Security\Ports\Directories;
use Application\Security\Models\Entities\SecurityAuditLogEntry;
use Application\Security\Models\ValueObjects\Entities\SecurityAuditLogEntryId;
use Shared\DDD\Port\Directory;
/**
* @extends Directory<SecurityAuditLogEntryId, SecurityAuditLogEntry>
*/
interface ClientAuditLogDirectory extends Directory
{
public function find(SecurityAuditLogEntryId $id): ?SecurityAuditLogEntry;
/**
* @return list<SecurityAuditLogEntry>
*/
public function findLogs(
\DateTimeImmutable $since,
?\DateTimeImmutable $to = null,
?string $clientIdentifier = null,
?string $eventType = null,
): array;
}

View file

@ -10,7 +10,7 @@ use Shared\DDD\Usecase\Usecase;
use Shared\Pagination;
use Webmozart\Assert\Assert;
/** @implements Usecase<Input, Output> */
/** @implements Usecase<\Application\Security\Usecases\FindClient\Input, \Application\Security\Usecases\FindClient\Output> */
final readonly class FindClient implements Usecase
{
public function __construct(

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Application\Security\Usecases\GetSecurityAuditLogs;
use Application\Security\Ports\Directories\ClientAuditLogDirectory;
use Shared\DDD\Usecase\Input as BaseInput;
use Shared\DDD\Usecase\Usecase;
use Webmozart\Assert\Assert;
/** @implements Usecase<\Application\Security\Usecases\GetSecurityAuditLogs\Input, \Application\Security\Usecases\GetSecurityAuditLogs\Output> */
final readonly class GetSecurityAuditLogs implements Usecase
{
public function __construct(
private ClientAuditLogDirectory $auditLogDirectory,
) {
}
#[\Override]
public function execute(BaseInput $input): Output
{
Assert::isInstanceOf($input, Input::class);
$to = $input->to ?? new \DateTimeImmutable();
$entries = $this->auditLogDirectory->findLogs(
since: $input->since,
to: $to,
clientIdentifier: $input->client,
eventType: $input->eventType,
);
return new Output(
$input->since,
$to,
...$entries
);
}
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Application\Security\Usecases\GetSecurityAuditLogs;
use Shared\DDD\Usecase\Input as BaseInput;
final readonly class Input implements BaseInput
{
public function __construct(
public \DateTimeImmutable $since,
public ?\DateTimeImmutable $to = null,
public ?string $client = null,
public ?string $eventType = null,
) {
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Application\Security\Usecases\GetSecurityAuditLogs;
use Application\Security\Models\Entities\SecurityAuditLogEntry;
use Shared\DDD\Usecase\Output as BaseOutput;
final readonly class Output implements BaseOutput
{
/**
* @var SecurityAuditLogEntry[]
*/
public array $entries;
public function __construct(
public \DateTimeImmutable $since,
public \DateTimeImmutable $to,
SecurityAuditLogEntry ...$entries,
) {
$this->entries = $entries;
}
public function count(): int
{
return count($this->entries);
}
}

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Infra\Adapters\Directories;
use Application\Security\Models\Entities\SecurityAuditLogEntry;
use Application\Security\Models\ValueObjects\Entities\SecurityAuditLogDetails;
use Application\Security\Models\ValueObjects\Entities\SecurityAuditLogEntryId;
use Application\Security\Ports\Directories\ClientAuditLogDirectory;
use Illuminate\Database\Eloquent\Builder;
use Infra\Database\Models\ClientAuditLog;
use Override;
final class EloquentClientAuditLogDirectory implements ClientAuditLogDirectory
{
#[Override]
public function find(SecurityAuditLogEntryId $id): ?SecurityAuditLogEntry
{
$record = ClientAuditLog::query()
->where('id', $id->identifier->toString())
->first();
if ($record === null) {
return null;
}
return $this->toDomainEntity($record);
}
#[Override]
public function findLogs(
\DateTimeImmutable $since,
?\DateTimeImmutable $to = null,
?string $clientIdentifier = null,
?string $eventType = null,
): array {
/** @var Builder<ClientAuditLog> $query */
$query = ClientAuditLog::query()
->where('created_at', '>=', $since->format('Y-m-d H:i:s'));
if ($to !== null) {
$query->where('created_at', '<=', $to->format('Y-m-d H:i:s'));
}
if ($clientIdentifier !== null && trim($clientIdentifier) !== '') {
$trimmed = trim($clientIdentifier);
$query->where(function (Builder $subQuery) use ($trimmed): void {
$subQuery->where('client_identifier', $trimmed)
->orWhere('client_id', $trimmed);
});
}
if ($eventType !== null && trim($eventType) !== '') {
$query->where('event_type', trim($eventType));
}
$records = $query
->orderBy('created_at', 'desc')
->get();
/** @var list<SecurityAuditLogEntry> */
return array_map(
[$this, 'toDomainEntity'],
$records->all()
);
}
private function toDomainEntity(ClientAuditLog $record): SecurityAuditLogEntry
{
return new SecurityAuditLogEntry(
id: new SecurityAuditLogEntryId($record->id),
clientId: $record->client_id,
clientIdentifier: $record->client_identifier->value,
eventType: $record->event_type,
details: $record->details ?? SecurityAuditLogDetails::empty(),
);
}
}

View file

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Infra\Casts;
use Application\Security\Models\ValueObjects\Entities\SecurityAuditLogDetails;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
/** @implements CastsAttributes<SecurityAuditLogDetails, array<string, string>|SecurityAuditLogDetails|string|null> */
final class SecurityAuditLogDetailsCast implements CastsAttributes
{
/**
* @param array<string, mixed> $attributes
*/
#[\Override]
public function get(Model $model, string $key, mixed $value, array $attributes): SecurityAuditLogDetails
{
if ($value === null || $value === '') {
return SecurityAuditLogDetails::empty();
}
if (is_string($value)) {
/** @var mixed $decoded */
$decoded = json_decode($value, true);
if (! is_array($decoded)) {
return SecurityAuditLogDetails::empty();
}
$value = $decoded;
}
if (! is_array($value)) {
throw new InvalidArgumentException('Details attribute must be an array, string, or null');
}
$values = [];
foreach ($value as $k => $v) {
$stringKey = (string) $k;
if (is_bool($v)) {
$values[$stringKey] = $v ? 'true' : 'false';
} elseif (is_scalar($v)) {
$values[$stringKey] = (string) $v;
}
}
return new SecurityAuditLogDetails($values);
}
/**
* @param array<string, mixed> $attributes
*/
#[\Override]
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
if ($value === null) {
return null;
}
if ($value instanceof SecurityAuditLogDetails) {
if ($value->isEmpty()) {
return null;
}
$json = json_encode($value->getValues(), JSON_UNESCAPED_SLASHES);
return $json !== false ? $json : null;
}
if (is_array($value)) {
if ($value === []) {
return null;
}
$stringValues = [];
foreach ($value as $k => $v) {
$stringKey = (string) $k;
if (is_bool($v)) {
$stringValues[$stringKey] = $v ? 'true' : 'false';
} elseif (is_scalar($v)) {
$stringValues[$stringKey] = (string) $v;
}
}
$json = json_encode($stringValues, JSON_UNESCAPED_SLASHES);
return $json !== false ? $json : null;
}
if (is_string($value)) {
return $value;
}
throw new InvalidArgumentException('Value must be null, array, string, or instance of SecurityAuditLogDetails');
}
}

View file

@ -6,15 +6,17 @@ namespace Infra\Database\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Infra\Casts\ClientIdentifierCast;
use Infra\Casts\SecurityAuditLogDetailsCast;
use Infra\Casts\UuidCast;
/**
* @property \Ramsey\Uuid\UuidInterface $id
* @property \Ramsey\Uuid\UuidInterface $client_id
* @property string $client_identifier
* @property \Application\Security\Models\ValueObjects\ClientIdentifier $client_identifier
* @property string $event_type
* @property array<string, mixed>|null $details
* @property \DateTimeInterface $created_at
* @property \Application\Security\Models\ValueObjects\Entities\SecurityAuditLogDetails|null $details
* @property \Illuminate\Support\Carbon $created_at
*
* @mixin \Eloquent
*/
@ -41,10 +43,11 @@ final class ClientAuditLog extends Model
protected function casts(): array
{
return [
'id' => UuidCast::class,
'client_id' => UuidCast::class,
'details' => 'array',
'created_at' => 'datetime',
'id' => UuidCast::class,
'client_id' => UuidCast::class,
'client_identifier' => ClientIdentifierCast::class,
'details' => SecurityAuditLogDetailsCast::class,
'created_at' => 'datetime',
];
}
}

View file

@ -22,7 +22,7 @@ final readonly class RecordClientSecurityAudit
$details = match (true) {
$event instanceof ClientStatusToggled => [
'is_active' => $event->isActive,
'is_active' => $event->isActive ? 'true' : 'false',
],
default => null,
};

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Infra\Providers;
use Application\Security\Ports\Directories\ClientAuditLogDirectory;
use Domain\Ports\Directories\ArcadiaBrandDirectory;
use Domain\Ports\Directories\EmployeeDirectory;
use Domain\Ports\Directories\IncoCompositionDirectory;
@ -14,6 +15,7 @@ use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\ServiceProvider;
use Infra\Adapters\Directories\EloquentArcadiaBrandDirectory;
use Infra\Adapters\Directories\EloquentClientAuditLogDirectory;
use Infra\Adapters\Directories\EloquentEmployeeDirectory;
use Infra\Adapters\Directories\EloquentIncoCompositionDirectory;
use Infra\Adapters\Directories\EloquentIncoSheetDirectory;
@ -37,6 +39,7 @@ class DomainServiceProvider extends ServiceProvider
$this->app->bind(IncoCompositionDirectory::class, EloquentIncoCompositionDirectory::class);
$this->app->bind(ArcadiaBrandDirectory::class, EloquentArcadiaBrandDirectory::class);
$this->app->bind(BrandRepository::class, EloquentBrandRepository::class);
$this->app->bind(ClientAuditLogDirectory::class, EloquentClientAuditLogDirectory::class);
$this->app->bind(BrandCache::class, static function (): BrandCache {
$storeName = Config::string('cache.arcadia_store');

View file

@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace Presentation\Console\Commands;
use Application\Security\Usecases\GetSecurityAuditLogs\GetSecurityAuditLogs;
use Application\Security\Usecases\GetSecurityAuditLogs\Input;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Presentation\Console\Presenters\Security\AuditLogRow;
use Presentation\Console\Presenters\Security\AuditLogsPresenter;
use Presentation\Console\Presenters\Security\AuditLogsView;
use Shared\DDD\Usecase\Builder\UsecaseBuilder;
use Shared\DDD\Usecase\Builder\UsecaseExecutor;
final class ExportSecurityAuditLogsCommand extends Command
{
/**
* @var string
*/
protected $signature = 'arcadia:security:audit-logs
{--since= : Retrieve events occurred since this date (e.g. "2026-08-01", "-7 days")}
{--to= : Retrieve events occurred up to this date (defaults to now)}
{--client= : Filter by client ID or identifier}
{--event= : Filter by event type (e.g. "client_secret_generated", "client_secret_regenerated", "client_status_toggled")}
{--csv : Export output to CSV file in storage/app/audit/}';
/**
* @var string
*/
protected $description = 'Display or export security audit events since a specified date';
public function handle(
GetSecurityAuditLogs $usecase,
UsecaseExecutor $executor,
AuditLogsPresenter $presenter,
): int {
$input = $this->buildInput();
if ($input === null) {
return self::FAILURE;
}
$view = UsecaseBuilder::using($executor)
->do($usecase)
->from($input)
->with($presenter)
->run();
if ($this->option('csv')) {
return $this->exportCsv($view);
}
return $this->renderConsoleTable($view);
}
private function buildInput(): ?Input
{
$sinceOption = $this->option('since');
$sinceString = is_string($sinceOption) && trim($sinceOption) !== ''
? trim($sinceOption)
: '-7 days';
try {
$since = new \DateTimeImmutable($sinceString);
} catch (\Exception) {
$this->error("Invalid date format for --since: '{$sinceString}'.");
return null;
}
$toOption = $this->option('to');
$to = null;
if (is_string($toOption) && trim($toOption) !== '') {
try {
$to = new \DateTimeImmutable(trim($toOption));
} catch (\Exception) {
$this->error("Invalid date format for --to: '{$toOption}'.");
return null;
}
}
$clientOption = $this->option('client');
$client = is_string($clientOption) && trim($clientOption) !== '' ? trim($clientOption) : null;
$eventOption = $this->option('event');
$eventType = is_string($eventOption) && trim($eventOption) !== '' ? trim($eventOption) : null;
return new Input(
since: $since,
to: $to,
client: $client,
eventType: $eventType,
);
}
private function renderConsoleTable(AuditLogsView $view): int
{
if ($view->isEmpty()) {
$this->info("No security audit logs found between {$view->since->format('Y-m-d H:i:s')} and {$view->to->format('Y-m-d H:i:s')}.");
return self::SUCCESS;
}
$tableRows = array_map(
static fn (AuditLogRow $row): array => [
$row->occurredAt->format('Y-m-d H:i:s'),
$row->eventType,
$row->clientIdentifier,
$row->clientId->toString(),
$row->details,
],
$view->rows
);
$this->table($view->headers, $tableRows);
$this->info("Total: {$view->total} event(s) found.");
return self::SUCCESS;
}
private function exportCsv(AuditLogsView $view): int
{
$dir = storage_path('app/audit');
if (! File::exists($dir)) {
File::makeDirectory($dir, 0755, true);
}
$filename = sprintf('security-audit-%s.csv', (new \DateTimeImmutable())->format('Y-m-d-His'));
$filePath = "{$dir}/{$filename}";
File::put($filePath, $view->toCsvContent());
$this->info("Exported {$view->total} security audit log(s) to {$filePath}");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Presentation\Console\Presenters\Security;
use Ramsey\Uuid\UuidInterface;
final readonly class AuditLogRow
{
public function __construct(
public \DateTimeInterface $occurredAt,
public string $eventType,
public string $clientIdentifier,
public UuidInterface $clientId,
public string $details,
) {
}
}

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Presentation\Console\Presenters\Security;
use Application\Security\Models\Entities\SecurityAuditLogEntry;
use Application\Security\Models\ValueObjects\Entities\SecurityAuditLogDetails;
use Application\Security\Usecases\GetSecurityAuditLogs\Output;
use Shared\DDD\Usecase\Output as UsecaseOutput;
use Shared\DDD\Usecase\Presenter;
use Webmozart\Assert\Assert;
/** @implements Presenter<Output, AuditLogsView> */
final readonly class AuditLogsPresenter implements Presenter
{
#[\Override]
public function present(UsecaseOutput $output): object
{
Assert::isInstanceOf($output, Output::class);
/** @var list<AuditLogRow> $rows */
$rows = array_values(array_map(
static function (SecurityAuditLogEntry $entry): AuditLogRow {
$detailsString = self::stringifyDetails($entry->details);
return new AuditLogRow(
occurredAt: $entry->createdAt(),
eventType: $entry->eventType,
clientIdentifier: $entry->clientIdentifier,
clientId: $entry->clientId,
details: $detailsString,
);
},
$output->entries
));
return new AuditLogsView(
headers: ['Date (UTC)', 'Event', 'Client Identifier', 'Client ID (UUID)', 'Details'],
rows: $rows,
total: $output->count(),
since: $output->since,
to: $output->to,
);
}
private static function stringifyDetails(SecurityAuditLogDetails $details): string
{
if ($details->isEmpty()) {
return '';
}
$values = $details->getValues();
$output = [];
foreach ($values as $key => $value) {
$output[] = sprintf('%s: %s', $key, $value);
}
return implode(', ', $output);
}
}

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Presentation\Console\Presenters\Security;
final readonly class AuditLogsView
{
/**
* @param list<string> $headers
* @param list<AuditLogRow> $rows
*/
public function __construct(
public array $headers,
public array $rows,
public int $total,
public \DateTimeImmutable $since,
public \DateTimeImmutable $to,
) {
}
public function isEmpty(): bool
{
return $this->total === 0;
}
public function toCsvContent(): string
{
$handle = fopen('php://temp', 'r+');
if ($handle === false) {
throw new \UnexpectedValueException('Failed to create temporary stream for CSV generation.');
}
fputcsv($handle, $this->headers);
foreach ($this->rows as $row) {
fputcsv($handle, [
$row->occurredAt->format('Y-m-d H:i:s'),
$row->eventType,
$row->clientIdentifier,
$row->clientId->toString(),
$row->details,
]);
}
rewind($handle);
$csv = stream_get_contents($handle);
fclose($handle);
return $csv !== false ? $csv : '';
}
}

View file

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Console\Commands;
use Illuminate\Support\Facades\File;
use Infra\Database\Models\ClientAuditLog;
use Presentation\Console\Commands\ExportSecurityAuditLogsCommand;
use Ramsey\Uuid\Uuid;
covers(ExportSecurityAuditLogsCommand::class);
describe('ExportSecurityAuditLogsCommand', function (): void {
beforeEach(function (): void {
ClientAuditLog::query()->delete();
File::deleteDirectory(storage_path('app/audit'));
});
it('displays security audit logs in console table format', function (): void {
// GIVEN
/** @var \Tests\TestCase $this */
$clientId = Uuid::uuid7()->toString();
$clientIdentifier = 'arcadia_id_' . str_repeat('g', 32);
$logId = Uuid::uuid7(new \DateTimeImmutable('2026-08-20 10:00:00 UTC'));
ClientAuditLog::query()->create([
'id' => $logId->toString(),
'client_id' => $clientId,
'client_identifier' => $clientIdentifier,
'event_type' => 'client_secret_generated',
'details' => null,
'created_at' => '2026-08-20 10:00:00',
]);
// WHEN
$this->artisan('arcadia:security:audit-logs', [
'--since' => '2026-08-01',
'--to' => '2026-08-21 23:59:59',
])
// THEN
->expectsTable(
['Date (UTC)', 'Event', 'Client Identifier', 'Client ID (UUID)', 'Details'],
[
[
'2026-08-20 10:00:00',
'client_secret_generated',
$clientIdentifier,
$clientId,
'',
],
]
)
->expectsOutput('Total: 1 event(s) found.')
->assertSuccessful();
});
it('displays notice when no logs are found in the specified range', function (): void {
// GIVEN
/** @var \Tests\TestCase $this */
// WHEN
$this->artisan('arcadia:security:audit-logs', [
'--since' => '2026-08-01',
'--to' => '2026-08-02',
])
// THEN
->expectsOutputToContain('No security audit logs found')
->assertSuccessful();
});
it('exports logs to CSV in storage directory when --csv is specified', function (): void {
// GIVEN
/** @var \Tests\TestCase $this */
$clientId = Uuid::uuid7()->toString();
$clientIdentifier = 'arcadia_id_' . str_repeat('e', 32);
$logId = Uuid::uuid7(new \DateTimeImmutable('2026-08-18 15:30:00 UTC'));
ClientAuditLog::query()->create([
'id' => $logId->toString(),
'client_id' => $clientId,
'client_identifier' => $clientIdentifier,
'event_type' => 'client_status_toggled',
'details' => ['is_active' => 'false'],
'created_at' => '2026-08-18 15:30:00',
]);
// WHEN
$this->artisan('arcadia:security:audit-logs', [
'--since' => '2026-08-01',
'--csv' => true,
])
// THEN
->expectsOutputToContain('Exported 1 security audit log(s) to')
->assertSuccessful();
$files = File::files(storage_path('app/audit'));
expect($files)->toHaveCount(1);
$csvContent = File::get($files[0]->getPathname());
expect($csvContent)->toContain($clientIdentifier)
->and($csvContent)->toContain('client_status_toggled')
->and($csvContent)->toContain('is_active');
});
it('fails when an invalid date format is provided for --since', function (): void {
// GIVEN & WHEN & THEN
/** @var \Tests\TestCase $this */
$this->artisan('arcadia:security:audit-logs', [
'--since' => 'invalid-date-string',
])
->expectsOutputToContain("Invalid date format for --since: 'invalid-date-string'.")
->assertFailed();
});
});

View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Tests\Integration\Infra\Adapters\Directories;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Infra\Adapters\Directories\EloquentClientAuditLogDirectory;
use Infra\Database\Models\ClientAuditLog;
use Ramsey\Uuid\Uuid;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
covers(EloquentClientAuditLogDirectory::class);
describe('EloquentClientAuditLogDirectory Integration Tests', function (): void {
beforeEach(function (): void {
ClientAuditLog::query()->delete();
});
it('retrieves logs filtered by date range, client and event type', function (): void {
// GIVEN
$clientId1 = Uuid::uuid7()->toString();
$clientId2 = Uuid::uuid7()->toString();
$clientIdentifier1 = 'arcadia_id_' . str_repeat('1', 32);
$clientIdentifier2 = 'arcadia_id_' . str_repeat('2', 32);
ClientAuditLog::query()->create([
'id' => Uuid::uuid7()->toString(),
'client_id' => $clientId1,
'client_identifier' => $clientIdentifier1,
'event_type' => 'client_secret_generated',
'details' => null,
'created_at' => '2026-08-01 10:00:00',
]);
ClientAuditLog::query()->create([
'id' => Uuid::uuid7()->toString(),
'client_id' => $clientId1,
'client_identifier' => $clientIdentifier1,
'event_type' => 'client_status_toggled',
'details' => ['is_active' => 'false'],
'created_at' => '2026-08-10 14:00:00',
]);
ClientAuditLog::query()->create([
'id' => Uuid::uuid7()->toString(),
'client_id' => $clientId2,
'client_identifier' => $clientIdentifier2,
'event_type' => 'client_secret_generated',
'details' => null,
'created_at' => '2026-08-15 12:00:00',
]);
$directory = new EloquentClientAuditLogDirectory();
// WHEN (Filter by date range and client1)
$since = new \DateTimeImmutable('2026-08-05 00:00:00');
$to = new \DateTimeImmutable('2026-08-20 23:59:59');
$results = $directory->findLogs(
since: $since,
to: $to,
clientIdentifier: $clientIdentifier1,
);
// THEN
expect($results)->toHaveCount(1)
->and($results[0]->clientIdentifier)->toBe($clientIdentifier1)
->and($results[0]->eventType)->toBe('client_status_toggled')
->and($results[0]->details->getValues())->toBe(['is_active' => 'false']);
});
});

View file

@ -9,6 +9,7 @@ use Application\Security\Events\ClientSecretRegenerated;
use Application\Security\Events\ClientStatusToggled;
use Application\Security\Models\ValueObjects\ClientIdentifier;
use Application\Security\Models\ValueObjects\Entities\ClientId;
use Application\Security\Models\ValueObjects\Entities\SecurityAuditLogDetails;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Infra\Database\Models\ClientAuditLog;
use Infra\Listeners\RecordClientSecurityAudit;
@ -47,9 +48,11 @@ describe('RecordClientSecurityAudit Listener Integration Tests', function (): vo
->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->client_identifier)->toBeInstanceOf(ClientIdentifier::class)
->and($log->client_identifier->value)->toBe($clientIdentifier->value)
->and($log->event_type)->toBe('client_secret_generated')
->and($log->details)->toBeNull()
->and($log->details)->toBeInstanceOf(SecurityAuditLogDetails::class)
->and($log->details->isEmpty())->toBeTrue()
->and($log->created_at->format('Y-m-d H:i:s'))->toBe('2026-08-21 10:00:00');
});
@ -73,9 +76,11 @@ describe('RecordClientSecurityAudit Listener Integration Tests', function (): vo
// THEN
$log = ClientAuditLog::query()->where('client_id', $clientId->value)->first();
expect($log)->not->toBeNull()
->and($log->client_identifier)->toBe($clientIdentifier->value)
->and($log->client_identifier)->toBeInstanceOf(ClientIdentifier::class)
->and($log->client_identifier->value)->toBe($clientIdentifier->value)
->and($log->event_type)->toBe('client_secret_regenerated')
->and($log->details)->toBeNull()
->and($log->details)->toBeInstanceOf(SecurityAuditLogDetails::class)
->and($log->details->isEmpty())->toBeTrue()
->and($log->created_at->format('Y-m-d H:i:s'))->toBe('2026-08-21 11:30:00');
});
@ -100,9 +105,11 @@ describe('RecordClientSecurityAudit Listener Integration Tests', function (): vo
// THEN
$log = ClientAuditLog::query()->where('client_id', $clientId->value)->first();
expect($log)->not->toBeNull()
->and($log->client_identifier)->toBe($clientIdentifier->value)
->and($log->client_identifier)->toBeInstanceOf(ClientIdentifier::class)
->and($log->client_identifier->value)->toBe($clientIdentifier->value)
->and($log->event_type)->toBe('client_status_toggled')
->and($log->details)->toBe(['is_active' => false])
->and($log->details)->toBeInstanceOf(SecurityAuditLogDetails::class)
->and($log->details->getValues())->toBe(['is_active' => 'false'])
->and($log->created_at->format('Y-m-d H:i:s'))->toBe('2026-08-21 12:00:00');
});
});

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Security\Models\ValueObjects\Entities;
use Application\Security\Models\ValueObjects\Entities\SecurityAuditLogDetails;
covers(SecurityAuditLogDetails::class);
describe('SecurityAuditLogDetails Value Object Unit Tests', function (): void {
it('creates empty details', function (): void {
// WHEN
$details = SecurityAuditLogDetails::empty();
// THEN
expect($details->isEmpty())->toBeTrue()
->and($details->getValues())->toBe([]);
});
it('stores and retrieves string map entries correctly', function (): void {
// GIVEN
$map = [
'is_active' => 'false',
'count' => '42',
'role' => 'admin',
];
// WHEN
$details = new SecurityAuditLogDetails($map);
// THEN
expect($details->isEmpty())->toBeFalse()
->and($details->get('is_active'))->toBe('false')
->and($details->get('count'))->toBe('42')
->and($details->get('role'))->toBe('admin')
->and($details->get('non_existent'))->toBeNull()
->and($details->getValues())->toBe($map);
});
it('handles equality correctly', function (): void {
// GIVEN
$details1 = new SecurityAuditLogDetails(['key' => 'value']);
$details2 = new SecurityAuditLogDetails(['key' => 'value']);
$details3 = new SecurityAuditLogDetails(['key' => 'other']);
// THEN
expect($details1->equals($details2))->toBeTrue()
->and($details1->equals($details3))->toBeFalse()
->and($details1->equals('key=value'))->toBeFalse();
});
});

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Security\Usecases;
use Application\Security\Models\Entities\SecurityAuditLogEntry;
use Application\Security\Models\ValueObjects\Entities\SecurityAuditLogDetails;
use Application\Security\Models\ValueObjects\Entities\SecurityAuditLogEntryId;
use Application\Security\Ports\Directories\ClientAuditLogDirectory;
use Application\Security\Usecases\GetSecurityAuditLogs\GetSecurityAuditLogs;
use Application\Security\Usecases\GetSecurityAuditLogs\Input;
use Mockery;
use Ramsey\Uuid\Uuid;
covers(GetSecurityAuditLogs::class);
describe('GetSecurityAuditLogs UseCase Unit Tests', function (): void {
it('executes and retrieves security audit logs from directory', function (): void {
// GIVEN
$since = new \DateTimeImmutable('2026-08-01 00:00:00');
$to = new \DateTimeImmutable('2026-08-21 23:59:59');
$entries = [
new SecurityAuditLogEntry(
id: new SecurityAuditLogEntryId(Uuid::fromString('01917b2f-7f41-7654-8c88-66280be02222')),
clientId: Uuid::fromString('01917b2f-7f41-7654-8c88-66280be02222'),
clientIdentifier: 'arcadia_id_test1',
eventType: 'client_secret_generated',
details: SecurityAuditLogDetails::empty(),
),
];
/** @var ClientAuditLogDirectory&Mockery\MockInterface $directoryMock */
$directoryMock = Mockery::mock(ClientAuditLogDirectory::class);
$directoryMock->shouldReceive('findLogs')
->once()
->with($since, $to, 'arcadia_id_test1', 'client_secret_generated')
->andReturn($entries);
$usecase = new GetSecurityAuditLogs($directoryMock);
$input = new Input(
since: $since,
to: $to,
client: 'arcadia_id_test1',
eventType: 'client_secret_generated',
);
// WHEN
$output = $usecase->execute($input);
// THEN
expect($output->entries)->toBe($entries)
->and($output->count())->toBe(1)
->and($output->since)->toBe($since)
->and($output->to)->toBe($to);
});
});

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Infra\Casts;
use Application\Security\Models\ValueObjects\Entities\SecurityAuditLogDetails;
use Infra\Casts\SecurityAuditLogDetailsCast;
use Infra\Database\Models\ClientAuditLog;
covers(SecurityAuditLogDetailsCast::class);
describe('SecurityAuditLogDetailsCast Unit Tests', function (): void {
it('returns empty details when value is null or empty string', function (): void {
// GIVEN
$cast = new SecurityAuditLogDetailsCast();
$model = new ClientAuditLog();
// WHEN
$resultNull = $cast->get($model, 'details', null, []);
$resultEmpty = $cast->get($model, 'details', '', []);
// THEN
expect($resultNull->isEmpty())->toBeTrue()
->and($resultEmpty->isEmpty())->toBeTrue();
});
it('decodes json string into SecurityAuditLogDetails', function (): void {
// GIVEN
$cast = new SecurityAuditLogDetailsCast();
$model = new ClientAuditLog();
$json = json_encode(['is_active' => 'false', 'role' => 'admin']);
// WHEN
$result = $cast->get($model, 'details', $json, []);
// THEN
expect($result->get('is_active'))->toBe('false')
->and($result->get('role'))->toBe('admin');
});
it('decodes array into SecurityAuditLogDetails', function (): void {
// GIVEN
$cast = new SecurityAuditLogDetailsCast();
$model = new ClientAuditLog();
$array = ['is_active' => false, 'count' => 10];
// WHEN
$result = $cast->get($model, 'details', $array, []);
// THEN
expect($result->get('is_active'))->toBe('false')
->and($result->get('count'))->toBe('10');
});
it('serializes SecurityAuditLogDetails or array to json on set', function (): void {
// GIVEN
$cast = new SecurityAuditLogDetailsCast();
$model = new ClientAuditLog();
$details = new SecurityAuditLogDetails(['is_active' => 'true']);
// WHEN
$setNull = $cast->set($model, 'details', null, []);
$setEmpty = $cast->set($model, 'details', SecurityAuditLogDetails::empty(), []);
$setDetails = $cast->set($model, 'details', $details, []);
$setArray = $cast->set($model, 'details', ['key' => 'val'], []);
// THEN
expect($setNull)->toBeNull()
->and($setEmpty)->toBeNull()
->and($setDetails)->toBe('{"is_active":"true"}')
->and($setArray)->toBe('{"key":"val"}');
});
});