feat(security): add arcadia:security:audit-logs CLI command with CSV export and typed audit details
This commit is contained in:
parent
61073f4fcd
commit
a82a1558bd
23 changed files with 1080 additions and 15 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
97
src/Infra/Casts/SecurityAuditLogDetailsCast.php
Normal file
97
src/Infra/Casts/SecurityAuditLogDetailsCast.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
19
src/Presentation/Console/Presenters/Security/AuditLogRow.php
Normal file
19
src/Presentation/Console/Presenters/Security/AuditLogRow.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 : '';
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
74
tests/Unit/Infra/Casts/SecurityAuditLogDetailsCastTest.php
Normal file
74
tests/Unit/Infra/Casts/SecurityAuditLogDetailsCastTest.php
Normal 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"}');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue