diff --git a/src/Application/Security/Models/Entities/SecurityAuditLogEntry.php b/src/Application/Security/Models/Entities/SecurityAuditLogEntry.php new file mode 100644 index 0000000..430c44b --- /dev/null +++ b/src/Application/Security/Models/Entities/SecurityAuditLogEntry.php @@ -0,0 +1,38 @@ + + */ +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(); + } +} diff --git a/src/Application/Security/Models/ValueObjects/Entities/SecurityAuditLogDetails.php b/src/Application/Security/Models/ValueObjects/Entities/SecurityAuditLogDetails.php new file mode 100644 index 0000000..5b652eb --- /dev/null +++ b/src/Application/Security/Models/ValueObjects/Entities/SecurityAuditLogDetails.php @@ -0,0 +1,54 @@ + $values + */ + public function __construct( + private array $values = [], + ) { + } + + /** + * @return array + */ + 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; + } +} diff --git a/src/Application/Security/Models/ValueObjects/Entities/SecurityAuditLogEntryId.php b/src/Application/Security/Models/ValueObjects/Entities/SecurityAuditLogEntryId.php new file mode 100644 index 0000000..e3c6150 --- /dev/null +++ b/src/Application/Security/Models/ValueObjects/Entities/SecurityAuditLogEntryId.php @@ -0,0 +1,27 @@ +toString()); + } + parent::__construct($identifier->toString()); + } + + public function toDateTime(): \DateTimeInterface + { + return UuidHelper::toDateTime($this->identifier); + } +} diff --git a/src/Application/Security/Ports/Directories/ClientAuditLogDirectory.php b/src/Application/Security/Ports/Directories/ClientAuditLogDirectory.php new file mode 100644 index 0000000..4877c53 --- /dev/null +++ b/src/Application/Security/Ports/Directories/ClientAuditLogDirectory.php @@ -0,0 +1,27 @@ + + */ +interface ClientAuditLogDirectory extends Directory +{ + public function find(SecurityAuditLogEntryId $id): ?SecurityAuditLogEntry; + + /** + * @return list + */ + public function findLogs( + \DateTimeImmutable $since, + ?\DateTimeImmutable $to = null, + ?string $clientIdentifier = null, + ?string $eventType = null, + ): array; +} diff --git a/src/Application/Security/Usecases/FindClient/FindClient.php b/src/Application/Security/Usecases/FindClient/FindClient.php index 0ac0cf9..b8d3610 100644 --- a/src/Application/Security/Usecases/FindClient/FindClient.php +++ b/src/Application/Security/Usecases/FindClient/FindClient.php @@ -10,7 +10,7 @@ use Shared\DDD\Usecase\Usecase; use Shared\Pagination; use Webmozart\Assert\Assert; -/** @implements Usecase */ +/** @implements Usecase<\Application\Security\Usecases\FindClient\Input, \Application\Security\Usecases\FindClient\Output> */ final readonly class FindClient implements Usecase { public function __construct( diff --git a/src/Application/Security/Usecases/GetSecurityAuditLogs/GetSecurityAuditLogs.php b/src/Application/Security/Usecases/GetSecurityAuditLogs/GetSecurityAuditLogs.php new file mode 100644 index 0000000..166f03f --- /dev/null +++ b/src/Application/Security/Usecases/GetSecurityAuditLogs/GetSecurityAuditLogs.php @@ -0,0 +1,40 @@ + */ +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 + ); + } +} diff --git a/src/Application/Security/Usecases/GetSecurityAuditLogs/Input.php b/src/Application/Security/Usecases/GetSecurityAuditLogs/Input.php new file mode 100644 index 0000000..42ff8db --- /dev/null +++ b/src/Application/Security/Usecases/GetSecurityAuditLogs/Input.php @@ -0,0 +1,18 @@ +entries = $entries; + } + + public function count(): int + { + return count($this->entries); + } +} diff --git a/src/Infra/Adapters/Directories/EloquentClientAuditLogDirectory.php b/src/Infra/Adapters/Directories/EloquentClientAuditLogDirectory.php new file mode 100644 index 0000000..05a8486 --- /dev/null +++ b/src/Infra/Adapters/Directories/EloquentClientAuditLogDirectory.php @@ -0,0 +1,79 @@ +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 $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 */ + 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(), + ); + } +} diff --git a/src/Infra/Casts/SecurityAuditLogDetailsCast.php b/src/Infra/Casts/SecurityAuditLogDetailsCast.php new file mode 100644 index 0000000..8f2b619 --- /dev/null +++ b/src/Infra/Casts/SecurityAuditLogDetailsCast.php @@ -0,0 +1,97 @@ +|SecurityAuditLogDetails|string|null> */ +final class SecurityAuditLogDetailsCast implements CastsAttributes +{ + /** + * @param array $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 $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'); + } +} diff --git a/src/Infra/Database/Models/ClientAuditLog.php b/src/Infra/Database/Models/ClientAuditLog.php index 5cf5a67..acaff3c 100644 --- a/src/Infra/Database/Models/ClientAuditLog.php +++ b/src/Infra/Database/Models/ClientAuditLog.php @@ -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|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', ]; } } diff --git a/src/Infra/Listeners/RecordClientSecurityAudit.php b/src/Infra/Listeners/RecordClientSecurityAudit.php index ed5708c..983037d 100644 --- a/src/Infra/Listeners/RecordClientSecurityAudit.php +++ b/src/Infra/Listeners/RecordClientSecurityAudit.php @@ -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, }; diff --git a/src/Infra/Providers/DomainServiceProvider.php b/src/Infra/Providers/DomainServiceProvider.php index 36f1fc5..b12eb09 100644 --- a/src/Infra/Providers/DomainServiceProvider.php +++ b/src/Infra/Providers/DomainServiceProvider.php @@ -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'); diff --git a/src/Presentation/Console/Commands/ExportSecurityAuditLogsCommand.php b/src/Presentation/Console/Commands/ExportSecurityAuditLogsCommand.php new file mode 100644 index 0000000..af8e8a4 --- /dev/null +++ b/src/Presentation/Console/Commands/ExportSecurityAuditLogsCommand.php @@ -0,0 +1,139 @@ +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; + } +} diff --git a/src/Presentation/Console/Presenters/Security/AuditLogRow.php b/src/Presentation/Console/Presenters/Security/AuditLogRow.php new file mode 100644 index 0000000..ceb1143 --- /dev/null +++ b/src/Presentation/Console/Presenters/Security/AuditLogRow.php @@ -0,0 +1,19 @@ + */ +final readonly class AuditLogsPresenter implements Presenter +{ + #[\Override] + public function present(UsecaseOutput $output): object + { + Assert::isInstanceOf($output, Output::class); + + /** @var list $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); + } +} diff --git a/src/Presentation/Console/Presenters/Security/AuditLogsView.php b/src/Presentation/Console/Presenters/Security/AuditLogsView.php new file mode 100644 index 0000000..1475382 --- /dev/null +++ b/src/Presentation/Console/Presenters/Security/AuditLogsView.php @@ -0,0 +1,52 @@ + $headers + * @param list $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 : ''; + } +} diff --git a/tests/Feature/Console/Commands/ExportSecurityAuditLogsCommandTest.php b/tests/Feature/Console/Commands/ExportSecurityAuditLogsCommandTest.php new file mode 100644 index 0000000..31358d8 --- /dev/null +++ b/tests/Feature/Console/Commands/ExportSecurityAuditLogsCommandTest.php @@ -0,0 +1,113 @@ +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(); + }); +}); diff --git a/tests/Integration/Infra/Adapters/Directories/EloquentClientAuditLogDirectoryTest.php b/tests/Integration/Infra/Adapters/Directories/EloquentClientAuditLogDirectoryTest.php new file mode 100644 index 0000000..46607f1 --- /dev/null +++ b/tests/Integration/Infra/Adapters/Directories/EloquentClientAuditLogDirectoryTest.php @@ -0,0 +1,73 @@ +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']); + }); +}); diff --git a/tests/Integration/Infra/Listeners/RecordClientSecurityAuditTest.php b/tests/Integration/Infra/Listeners/RecordClientSecurityAuditTest.php index e5c09cb..24a85c1 100644 --- a/tests/Integration/Infra/Listeners/RecordClientSecurityAuditTest.php +++ b/tests/Integration/Infra/Listeners/RecordClientSecurityAuditTest.php @@ -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'); }); }); diff --git a/tests/Unit/Application/Security/Models/ValueObjects/Entities/SecurityAuditLogDetailsTest.php b/tests/Unit/Application/Security/Models/ValueObjects/Entities/SecurityAuditLogDetailsTest.php new file mode 100644 index 0000000..f7aafd9 --- /dev/null +++ b/tests/Unit/Application/Security/Models/ValueObjects/Entities/SecurityAuditLogDetailsTest.php @@ -0,0 +1,52 @@ +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(); + }); +}); diff --git a/tests/Unit/Application/Security/Usecases/GetSecurityAuditLogsTest.php b/tests/Unit/Application/Security/Usecases/GetSecurityAuditLogsTest.php new file mode 100644 index 0000000..a071536 --- /dev/null +++ b/tests/Unit/Application/Security/Usecases/GetSecurityAuditLogsTest.php @@ -0,0 +1,58 @@ +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); + }); +}); diff --git a/tests/Unit/Infra/Casts/SecurityAuditLogDetailsCastTest.php b/tests/Unit/Infra/Casts/SecurityAuditLogDetailsCastTest.php new file mode 100644 index 0000000..719929b --- /dev/null +++ b/tests/Unit/Infra/Casts/SecurityAuditLogDetailsCastTest.php @@ -0,0 +1,74 @@ +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"}'); + }); +});