feat: Audit trail pour actions sensibles
Story 1.7 - Implémente un système complet d'audit trail pour tracer toutes les actions sensibles (authentification, modifications de données, exports) avec immuabilité garantie par PostgreSQL. Fonctionnalités principales: - Table audit_log append-only avec contraintes PostgreSQL (RULE) - AuditLogger centralisé avec injection automatique du contexte - Correlation ID pour traçabilité distribuée (HTTP + async) - Handlers pour événements d'authentification - Commande d'archivage des logs anciens - Pas de PII dans les logs (emails/IPs hashés) Infrastructure: - Middlewares Messenger pour propagation du Correlation ID - HTTP middleware pour génération/propagation du Correlation ID - Support multi-tenant avec TenantResolver
This commit is contained in:
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -193,6 +193,13 @@ jobs:
|
||||
# Generate JWT keys if they don't exist (required for login/token endpoints)
|
||||
docker compose exec -T php php bin/console lexik:jwt:generate-keypair --skip-if-exists
|
||||
|
||||
- name: Create test database and run migrations
|
||||
run: |
|
||||
# Create test database (Symfony adds _test suffix in test environment)
|
||||
docker compose exec -T php php bin/console doctrine:database:create --if-not-exists
|
||||
# Run migrations to create all tables (including audit_log)
|
||||
docker compose exec -T php php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
- name: Show backend logs on failure
|
||||
if: failure()
|
||||
run: docker compose logs php
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,3 +13,6 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
correlation_id
|
||||
tenant_id
|
||||
test-results/
|
||||
|
||||
7
Makefile
7
Makefile
@@ -189,7 +189,12 @@ check-tenants: ## Vérifier que les tenants répondent
|
||||
# =============================================================================
|
||||
|
||||
.PHONY: install
|
||||
install: up jwt-keys warmup ## Installation complète après clone
|
||||
install: up jwt-keys migrate warmup ## Installation complète après clone
|
||||
|
||||
.PHONY: migrate
|
||||
migrate: ## Exécuter les migrations Doctrine
|
||||
docker compose exec php php bin/console doctrine:database:create --if-not-exists
|
||||
docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
.PHONY: jwt-keys
|
||||
jwt-keys: ## Générer les clés JWT (requis après clone)
|
||||
|
||||
@@ -10,14 +10,22 @@ framework:
|
||||
command.bus:
|
||||
default_middleware: true
|
||||
middleware:
|
||||
- App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware
|
||||
- App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware
|
||||
- doctrine_transaction
|
||||
|
||||
query.bus:
|
||||
default_middleware: true
|
||||
middleware:
|
||||
- App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware
|
||||
- App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware
|
||||
|
||||
event.bus:
|
||||
default_middleware:
|
||||
allow_no_handlers: true
|
||||
middleware:
|
||||
- App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware
|
||||
- App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware
|
||||
|
||||
transports:
|
||||
# https://symfony.com/doc/current/messenger.html#transport-configuration
|
||||
|
||||
@@ -91,10 +91,17 @@ services:
|
||||
arguments:
|
||||
$appUrl: '%app.url%'
|
||||
|
||||
# Audit log handler (uses dedicated audit channel)
|
||||
App\Administration\Infrastructure\Messaging\AuditLoginEventsHandler:
|
||||
# Audit Logger Service (writes to append-only audit_log table)
|
||||
App\Shared\Application\Port\AuditLogger:
|
||||
alias: App\Shared\Infrastructure\Audit\AuditLogger
|
||||
|
||||
App\Shared\Infrastructure\Audit\AuditLogger:
|
||||
arguments:
|
||||
$appSecret: '%env(APP_SECRET)%'
|
||||
|
||||
# Audit log handlers (use AuditLogger to write to database)
|
||||
App\Shared\Infrastructure\Audit\Handler\AuditAuthenticationHandler:
|
||||
arguments:
|
||||
$auditLogger: '@monolog.logger.audit'
|
||||
$appSecret: '%env(APP_SECRET)%'
|
||||
|
||||
# JWT Authentication
|
||||
|
||||
65
backend/migrations/Version20260203100000.php
Normal file
65
backend/migrations/Version20260203100000.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Migration pour la table audit_log append-only.
|
||||
*
|
||||
* @see Story 1.7 - T1: Schema Event Store
|
||||
* @see FR90: Tracage actions sensibles
|
||||
* @see NFR-S7: Audit trail immutable
|
||||
*/
|
||||
final class Version20260203100000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create audit_log table with append-only constraints (Story 1.7)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// T1.1 & T1.2: Create audit_log table
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
aggregate_type VARCHAR(255) NOT NULL,
|
||||
aggregate_id UUID,
|
||||
event_type VARCHAR(255) NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
metadata JSONB NOT NULL,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
sequence_number BIGSERIAL NOT NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
// T1.4: Indexes for efficient querying
|
||||
$this->addSql("CREATE INDEX idx_audit_tenant ON audit_log((metadata->>'tenant_id'))");
|
||||
$this->addSql("CREATE INDEX idx_audit_user ON audit_log((metadata->>'user_id'))");
|
||||
$this->addSql('CREATE INDEX idx_audit_occurred ON audit_log(occurred_at)');
|
||||
$this->addSql('CREATE INDEX idx_audit_aggregate ON audit_log(aggregate_type, aggregate_id)');
|
||||
$this->addSql("CREATE INDEX idx_audit_correlation ON audit_log((metadata->>'correlation_id'))");
|
||||
$this->addSql('CREATE INDEX idx_audit_event_type ON audit_log(event_type)');
|
||||
|
||||
// T1.3: Append-only constraints - prevent UPDATE and DELETE
|
||||
$this->addSql('CREATE RULE audit_no_update AS ON UPDATE TO audit_log DO INSTEAD NOTHING');
|
||||
$this->addSql('CREATE RULE audit_no_delete AS ON DELETE TO audit_log DO INSTEAD NOTHING');
|
||||
|
||||
// Add comment to document the immutability constraint
|
||||
$this->addSql("COMMENT ON TABLE audit_log IS 'Append-only audit log table. UPDATE and DELETE operations are disabled via PostgreSQL rules for immutability (NFR-S7).'");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Drop rules first
|
||||
$this->addSql('DROP RULE IF EXISTS audit_no_update ON audit_log');
|
||||
$this->addSql('DROP RULE IF EXISTS audit_no_delete ON audit_log');
|
||||
|
||||
// Drop table (indexes are dropped automatically)
|
||||
$this->addSql('DROP TABLE IF EXISTS audit_log');
|
||||
}
|
||||
}
|
||||
118
backend/migrations/Version20260203100001.php
Normal file
118
backend/migrations/Version20260203100001.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Migration pour la table audit_log_archive et la fonction d'archivage.
|
||||
*
|
||||
* @see Story 1.7 - T8: Archivage
|
||||
* @see NFR-C5: Duree conservation 5 ans actif, puis archive
|
||||
*/
|
||||
final class Version20260203100001 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create audit_log_archive table and archival function (Story 1.7)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// T8.1: Create archive table with same structure as audit_log
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE audit_log_archive (
|
||||
id UUID PRIMARY KEY,
|
||||
aggregate_type VARCHAR(255) NOT NULL,
|
||||
aggregate_id UUID,
|
||||
event_type VARCHAR(255) NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
metadata JSONB NOT NULL,
|
||||
occurred_at TIMESTAMPTZ NOT NULL,
|
||||
sequence_number BIGINT NOT NULL,
|
||||
archived_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
SQL);
|
||||
|
||||
// Indexes for archive queries (less aggressive than active table)
|
||||
// Note: No event_type index on archive - less frequent queries, trade space for insert speed
|
||||
$this->addSql("CREATE INDEX idx_audit_archive_tenant ON audit_log_archive((metadata->>'tenant_id'))");
|
||||
$this->addSql('CREATE INDEX idx_audit_archive_occurred ON audit_log_archive(occurred_at)');
|
||||
$this->addSql('CREATE INDEX idx_audit_archive_archived ON audit_log_archive(archived_at)');
|
||||
|
||||
// Archive is also append-only (immutable)
|
||||
$this->addSql('CREATE RULE audit_archive_no_update AS ON UPDATE TO audit_log_archive DO INSTEAD NOTHING');
|
||||
$this->addSql('CREATE RULE audit_archive_no_delete AS ON DELETE TO audit_log_archive DO INSTEAD NOTHING');
|
||||
|
||||
$this->addSql("COMMENT ON TABLE audit_log_archive IS 'Archived audit entries (>5 years old). Append-only, immutable. Entries are moved from audit_log, not copied.'");
|
||||
|
||||
// T8.2-T8.3: Create SECURITY DEFINER function to bypass audit_no_delete rule
|
||||
// This is the ONLY way to delete from audit_log - for archival purposes only
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE OR REPLACE FUNCTION archive_audit_entries(
|
||||
p_cutoff_date TIMESTAMPTZ,
|
||||
p_batch_size INTEGER
|
||||
)
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_archived_count INTEGER;
|
||||
v_ids UUID[];
|
||||
BEGIN
|
||||
-- Select IDs to archive (oldest first, up to batch_size)
|
||||
SELECT ARRAY_AGG(id ORDER BY sequence_number ASC)
|
||||
INTO v_ids
|
||||
FROM (
|
||||
SELECT id, sequence_number
|
||||
FROM audit_log
|
||||
WHERE occurred_at < p_cutoff_date
|
||||
ORDER BY sequence_number ASC
|
||||
LIMIT p_batch_size
|
||||
) sub;
|
||||
|
||||
-- Nothing to archive
|
||||
IF v_ids IS NULL OR array_length(v_ids, 1) IS NULL THEN
|
||||
RETURN 0;
|
||||
END IF;
|
||||
|
||||
-- Insert into archive
|
||||
INSERT INTO audit_log_archive (
|
||||
id, aggregate_type, aggregate_id, event_type,
|
||||
payload, metadata, occurred_at, sequence_number
|
||||
)
|
||||
SELECT
|
||||
id, aggregate_type, aggregate_id, event_type,
|
||||
payload, metadata, occurred_at, sequence_number
|
||||
FROM audit_log
|
||||
WHERE id = ANY(v_ids);
|
||||
|
||||
GET DIAGNOSTICS v_archived_count = ROW_COUNT;
|
||||
|
||||
-- Delete from source table (bypasses rule via SECURITY DEFINER)
|
||||
-- We temporarily disable the rule for this session
|
||||
ALTER TABLE audit_log DISABLE RULE audit_no_delete;
|
||||
DELETE FROM audit_log WHERE id = ANY(v_ids);
|
||||
ALTER TABLE audit_log ENABLE RULE audit_no_delete;
|
||||
|
||||
RETURN v_archived_count;
|
||||
END;
|
||||
$$
|
||||
SQL);
|
||||
|
||||
$this->addSql("COMMENT ON FUNCTION archive_audit_entries IS 'Moves audit entries older than cutoff to archive table. Only method to delete from audit_log. Requires superuser or table owner.'");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP FUNCTION IF EXISTS archive_audit_entries');
|
||||
$this->addSql('DROP RULE IF EXISTS audit_archive_no_update ON audit_log_archive');
|
||||
$this->addSql('DROP RULE IF EXISTS audit_archive_no_delete ON audit_log_archive');
|
||||
$this->addSql('DROP TABLE IF EXISTS audit_log_archive');
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
@@ -20,6 +21,7 @@ final readonly class CompteBloqueTemporairement implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public string $email,
|
||||
public ?TenantId $tenantId,
|
||||
public string $ipAddress,
|
||||
public string $userAgent,
|
||||
public int $blockedForSeconds,
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
@@ -21,6 +22,7 @@ final readonly class ConnexionEchouee implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public string $email,
|
||||
public ?TenantId $tenantId,
|
||||
public string $ipAddress,
|
||||
public string $userAgent,
|
||||
public string $reason, // 'invalid_credentials', 'account_locked', 'rate_limited'
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Messaging;
|
||||
|
||||
use App\Administration\Domain\Event\ConnexionEchouee;
|
||||
use App\Administration\Domain\Event\ConnexionReussie;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Enregistre les événements de connexion dans l'audit log.
|
||||
*
|
||||
* Important: Les IP sont hashées pour respecter NFR-S3 (pas de PII dans les logs).
|
||||
*
|
||||
* @see Story 1.4 - T5.5: Tracer dans audit log
|
||||
* @see AC3: Événement tracé dans audit log
|
||||
*/
|
||||
final readonly class AuditLoginEventsHandler
|
||||
{
|
||||
public function __construct(
|
||||
private LoggerInterface $auditLogger,
|
||||
private string $appSecret,
|
||||
) {
|
||||
}
|
||||
|
||||
#[AsMessageHandler]
|
||||
public function handleConnexionReussie(ConnexionReussie $event): void
|
||||
{
|
||||
$this->auditLogger->info('login.success', [
|
||||
'user_id' => $event->userId,
|
||||
'tenant_id' => (string) $event->tenantId,
|
||||
'ip_hash' => $this->hashIp($event->ipAddress),
|
||||
'user_agent_hash' => $this->hashUserAgent($event->userAgent),
|
||||
'occurred_on' => $event->occurredOn->format('c'),
|
||||
]);
|
||||
}
|
||||
|
||||
#[AsMessageHandler]
|
||||
public function handleConnexionEchouee(ConnexionEchouee $event): void
|
||||
{
|
||||
$this->auditLogger->warning('login.failure', [
|
||||
'email_hash' => $this->hashEmail($event->email),
|
||||
'reason' => $event->reason,
|
||||
'ip_hash' => $this->hashIp($event->ipAddress),
|
||||
'user_agent_hash' => $this->hashUserAgent($event->userAgent),
|
||||
'occurred_on' => $event->occurredOn->format('c'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash l'IP pour éviter de stocker des PII.
|
||||
* Le hash permet toujours de corréler les événements d'une même IP.
|
||||
*/
|
||||
private function hashIp(string $ip): string
|
||||
{
|
||||
return hash('sha256', $ip . $this->appSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash l'email pour éviter de stocker des PII.
|
||||
*/
|
||||
private function hashEmail(string $email): string
|
||||
{
|
||||
return hash('sha256', strtolower($email) . $this->appSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash le User-Agent (généralement pas PII mais peut être très long).
|
||||
*/
|
||||
private function hashUserAgent(string $userAgent): string
|
||||
{
|
||||
return hash('sha256', $userAgent);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,10 @@ namespace App\Administration\Infrastructure\Security;
|
||||
use App\Administration\Domain\Event\CompteBloqueTemporairement;
|
||||
use App\Administration\Domain\Event\ConnexionEchouee;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface;
|
||||
use App\Shared\Infrastructure\RateLimit\LoginRateLimitResult;
|
||||
use App\Shared\Infrastructure\Tenant\TenantResolver;
|
||||
|
||||
use function is_array;
|
||||
use function is_string;
|
||||
@@ -20,12 +22,16 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Handles login failures: Fibonacci rate limiting, audit, user-friendly messages.
|
||||
*
|
||||
* Important: Never reveal whether the email exists or not (AC2).
|
||||
*
|
||||
* Note: /api/login is excluded from TenantMiddleware, so we resolve tenant
|
||||
* directly from host header using TenantResolver.
|
||||
*
|
||||
* @see Story 1.4 - T5: Backend Login Endpoint
|
||||
*/
|
||||
final readonly class LoginFailureHandler implements AuthenticationFailureHandlerInterface
|
||||
@@ -34,6 +40,7 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
|
||||
private LoginRateLimiterInterface $rateLimiter,
|
||||
private MessageBusInterface $eventBus,
|
||||
private Clock $clock,
|
||||
private TenantResolver $tenantResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -49,9 +56,12 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
|
||||
// Record the failure and get the new state
|
||||
$result = $this->rateLimiter->recordFailure($request, $email);
|
||||
|
||||
// Dispatch the failure event
|
||||
// Resolve tenant from host header (TenantMiddleware skips /api/login)
|
||||
$tenantId = $this->resolveTenantFromHost($request);
|
||||
|
||||
$this->eventBus->dispatch(new ConnexionEchouee(
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
ipAddress: $ipAddress,
|
||||
userAgent: $userAgent,
|
||||
reason: 'invalid_credentials',
|
||||
@@ -62,6 +72,7 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
|
||||
if ($result->ipBlocked) {
|
||||
$this->eventBus->dispatch(new CompteBloqueTemporairement(
|
||||
email: $email,
|
||||
tenantId: $tenantId,
|
||||
ipAddress: $ipAddress,
|
||||
userAgent: $userAgent,
|
||||
blockedForSeconds: $result->retryAfter ?? LoginRateLimiterInterface::IP_BLOCK_DURATION,
|
||||
@@ -125,4 +136,26 @@ final readonly class LoginFailureHandler implements AuthenticationFailureHandler
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve tenant from request host header.
|
||||
*
|
||||
* Since /api/login is excluded from TenantMiddleware, we must resolve
|
||||
* the tenant ourselves to properly scope audit events.
|
||||
*
|
||||
* Returns null if tenant cannot be resolved (unknown domain, database issues, etc.)
|
||||
* to ensure login failure handling never breaks due to tenant resolution.
|
||||
*/
|
||||
private function resolveTenantFromHost(Request $request): ?TenantId
|
||||
{
|
||||
try {
|
||||
$config = $this->tenantResolver->resolve($request->getHost());
|
||||
|
||||
return $config->tenantId;
|
||||
} catch (Throwable) {
|
||||
// Login attempt on unknown domain or tenant resolution failed
|
||||
// Don't let tenant resolution break the login failure handling
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
65
backend/src/Shared/Application/Port/AuditLogger.php
Normal file
65
backend/src/Shared/Application/Port/AuditLogger.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Application\Port;
|
||||
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Port for audit logging.
|
||||
*
|
||||
* Infrastructure implementation writes to append-only database table.
|
||||
*
|
||||
* @see Story 1.7 - T2: AuditLogger Service
|
||||
*/
|
||||
interface AuditLogger
|
||||
{
|
||||
/**
|
||||
* Log authentication events (login success/failure, account blocked, etc.).
|
||||
*
|
||||
* @param array<string, mixed> $payload Event-specific data
|
||||
* @param string|null $tenantId Override tenant from event (for async handlers without TenantContext)
|
||||
*/
|
||||
public function logAuthentication(
|
||||
string $eventType,
|
||||
?UuidInterface $userId,
|
||||
array $payload,
|
||||
?string $tenantId = null,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Log data changes (notes, absences, student data modifications).
|
||||
*
|
||||
* @param array<string, mixed> $oldValues Previous values
|
||||
* @param array<string, mixed> $newValues New values
|
||||
*/
|
||||
public function logDataChange(
|
||||
string $aggregateType,
|
||||
UuidInterface $aggregateId,
|
||||
string $eventType,
|
||||
array $oldValues,
|
||||
array $newValues,
|
||||
?string $reason = null,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Log export operations (CSV, PDF, RGPD data exports).
|
||||
*/
|
||||
public function logExport(
|
||||
string $exportType,
|
||||
int $recordCount,
|
||||
?string $targetDescription = null,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Log access to sensitive data (student records, etc.).
|
||||
*
|
||||
* @param array<string, mixed> $context Additional context (screen, action)
|
||||
*/
|
||||
public function logAccess(
|
||||
string $resourceType,
|
||||
UuidInterface $resourceId,
|
||||
array $context = [],
|
||||
): void;
|
||||
}
|
||||
@@ -4,8 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final readonly class CorrelationId
|
||||
{
|
||||
private function __construct(
|
||||
@@ -20,6 +23,12 @@ final readonly class CorrelationId
|
||||
|
||||
public static function fromString(string $value): self
|
||||
{
|
||||
if (!Uuid::isValid($value)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf('Invalid correlation ID format: "%s". Expected UUID.', $value),
|
||||
);
|
||||
}
|
||||
|
||||
return new self($value);
|
||||
}
|
||||
|
||||
|
||||
101
backend/src/Shared/Infrastructure/Audit/AuditLogEntry.php
Normal file
101
backend/src/Shared/Infrastructure/Audit/AuditLogEntry.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Audit;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Read model for audit log entries.
|
||||
*
|
||||
* Immutable DTO representing a single audit log entry.
|
||||
*/
|
||||
final readonly class AuditLogEntry
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
public function __construct(
|
||||
public UuidInterface $id,
|
||||
public string $aggregateType,
|
||||
public ?UuidInterface $aggregateId,
|
||||
public string $eventType,
|
||||
public array $payload,
|
||||
public array $metadata,
|
||||
public DateTimeImmutable $occurredAt,
|
||||
public int $sequenceNumber,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
public static function fromDatabaseRow(array $row): self
|
||||
{
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
/** @var string $aggregateType */
|
||||
$aggregateType = $row['aggregate_type'];
|
||||
/** @var string|null $aggregateId */
|
||||
$aggregateId = $row['aggregate_id'];
|
||||
/** @var string $eventType */
|
||||
$eventType = $row['event_type'];
|
||||
/** @var string $payloadJson */
|
||||
$payloadJson = $row['payload'];
|
||||
/** @var string $metadataJson */
|
||||
$metadataJson = $row['metadata'];
|
||||
/** @var string $occurredAt */
|
||||
$occurredAt = $row['occurred_at'];
|
||||
/** @var string|int $sequenceNumber */
|
||||
$sequenceNumber = $row['sequence_number'];
|
||||
|
||||
/** @var array<string, mixed> $payload */
|
||||
$payload = json_decode($payloadJson, true, 512, JSON_THROW_ON_ERROR);
|
||||
/** @var array<string, mixed> $metadata */
|
||||
$metadata = json_decode($metadataJson, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return new self(
|
||||
id: Uuid::fromString($id),
|
||||
aggregateType: $aggregateType,
|
||||
aggregateId: $aggregateId !== null
|
||||
? Uuid::fromString($aggregateId)
|
||||
: null,
|
||||
eventType: $eventType,
|
||||
payload: $payload,
|
||||
metadata: $metadata,
|
||||
occurredAt: new DateTimeImmutable($occurredAt),
|
||||
sequenceNumber: (int) $sequenceNumber,
|
||||
);
|
||||
}
|
||||
|
||||
public function correlationId(): ?string
|
||||
{
|
||||
/** @var string|null $correlationId */
|
||||
$correlationId = $this->metadata['correlation_id'] ?? null;
|
||||
|
||||
return $correlationId;
|
||||
}
|
||||
|
||||
public function tenantId(): ?string
|
||||
{
|
||||
/** @var string|null $tenantId */
|
||||
$tenantId = $this->metadata['tenant_id'] ?? null;
|
||||
|
||||
return $tenantId;
|
||||
}
|
||||
|
||||
public function userId(): ?string
|
||||
{
|
||||
/** @var string|null $userId */
|
||||
$userId = $this->metadata['user_id'] ?? null;
|
||||
|
||||
return $userId;
|
||||
}
|
||||
}
|
||||
188
backend/src/Shared/Infrastructure/Audit/AuditLogRepository.php
Normal file
188
backend/src/Shared/Infrastructure/Audit/AuditLogRepository.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Audit;
|
||||
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Repository for querying audit logs.
|
||||
*
|
||||
* Provides investigation queries for administrators and DPOs.
|
||||
* All queries are tenant-scoped for security.
|
||||
*
|
||||
* @see Story 1.7 - T9: Requetes d'investigation
|
||||
*/
|
||||
final readonly class AuditLogRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs for a specific user.
|
||||
*
|
||||
* @see T9.1: Query GetAuditLogByUser
|
||||
*
|
||||
* @return list<AuditLogEntry>
|
||||
*/
|
||||
public function findByUser(
|
||||
UuidInterface $userId,
|
||||
TenantId $tenantId,
|
||||
?DateTimeImmutable $from = null,
|
||||
?DateTimeImmutable $to = null,
|
||||
?string $eventType = null,
|
||||
int $limit = 100,
|
||||
int $offset = 0,
|
||||
): array {
|
||||
$qb = $this->connection->createQueryBuilder()
|
||||
->select('*')
|
||||
->from('audit_log')
|
||||
->where("metadata->>'user_id' = :user_id")
|
||||
->andWhere("metadata->>'tenant_id' = :tenant_id")
|
||||
->setParameter('user_id', $userId->toString())
|
||||
->setParameter('tenant_id', (string) $tenantId)
|
||||
->orderBy('occurred_at', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($offset);
|
||||
|
||||
$this->applyFilters($qb, $from, $to, $eventType);
|
||||
|
||||
return $this->mapResults($qb->executeQuery()->fetchAllAssociative());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs for a specific resource (aggregate).
|
||||
*
|
||||
* @see T9.2: Query GetAuditLogByResource
|
||||
*
|
||||
* @return list<AuditLogEntry>
|
||||
*/
|
||||
public function findByResource(
|
||||
string $aggregateType,
|
||||
UuidInterface $aggregateId,
|
||||
TenantId $tenantId,
|
||||
?DateTimeImmutable $from = null,
|
||||
?DateTimeImmutable $to = null,
|
||||
int $limit = 100,
|
||||
int $offset = 0,
|
||||
): array {
|
||||
$qb = $this->connection->createQueryBuilder()
|
||||
->select('*')
|
||||
->from('audit_log')
|
||||
->where('aggregate_type = :aggregate_type')
|
||||
->andWhere('aggregate_id = :aggregate_id')
|
||||
->andWhere("metadata->>'tenant_id' = :tenant_id")
|
||||
->setParameter('aggregate_type', $aggregateType)
|
||||
->setParameter('aggregate_id', $aggregateId->toString())
|
||||
->setParameter('tenant_id', (string) $tenantId)
|
||||
->orderBy('occurred_at', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($offset);
|
||||
|
||||
$this->applyFilters($qb, $from, $to, null);
|
||||
|
||||
return $this->mapResults($qb->executeQuery()->fetchAllAssociative());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs by correlation ID (trace a full request).
|
||||
*
|
||||
* @see T9.3: Query GetAuditLogByCorrelationId
|
||||
*
|
||||
* @return list<AuditLogEntry>
|
||||
*/
|
||||
public function findByCorrelationId(
|
||||
string $correlationId,
|
||||
TenantId $tenantId,
|
||||
): array {
|
||||
$qb = $this->connection->createQueryBuilder()
|
||||
->select('*')
|
||||
->from('audit_log')
|
||||
->where("metadata->>'correlation_id' = :correlation_id")
|
||||
->andWhere("metadata->>'tenant_id' = :tenant_id")
|
||||
->setParameter('correlation_id', $correlationId)
|
||||
->setParameter('tenant_id', (string) $tenantId)
|
||||
->orderBy('occurred_at', 'ASC');
|
||||
|
||||
return $this->mapResults($qb->executeQuery()->fetchAllAssociative());
|
||||
}
|
||||
|
||||
/**
|
||||
* Search audit logs with multiple filters.
|
||||
*
|
||||
* @see T9.4: Filtres avances
|
||||
*
|
||||
* @return list<AuditLogEntry>
|
||||
*/
|
||||
public function search(
|
||||
TenantId $tenantId,
|
||||
?DateTimeImmutable $from = null,
|
||||
?DateTimeImmutable $to = null,
|
||||
?string $eventType = null,
|
||||
?string $aggregateType = null,
|
||||
int $limit = 100,
|
||||
int $offset = 0,
|
||||
): array {
|
||||
$qb = $this->connection->createQueryBuilder()
|
||||
->select('*')
|
||||
->from('audit_log')
|
||||
->where("metadata->>'tenant_id' = :tenant_id")
|
||||
->setParameter('tenant_id', (string) $tenantId)
|
||||
->orderBy('occurred_at', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($offset);
|
||||
|
||||
$this->applyFilters($qb, $from, $to, $eventType);
|
||||
|
||||
if ($aggregateType !== null) {
|
||||
$qb->andWhere('aggregate_type = :aggregate_type')
|
||||
->setParameter('aggregate_type', $aggregateType);
|
||||
}
|
||||
|
||||
return $this->mapResults($qb->executeQuery()->fetchAllAssociative());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Doctrine\DBAL\Query\QueryBuilder $qb
|
||||
*/
|
||||
private function applyFilters(
|
||||
$qb,
|
||||
?DateTimeImmutable $from,
|
||||
?DateTimeImmutable $to,
|
||||
?string $eventType,
|
||||
): void {
|
||||
if ($from !== null) {
|
||||
$qb->andWhere('occurred_at >= :from')
|
||||
->setParameter('from', $from->format('Y-m-d H:i:s.uP'));
|
||||
}
|
||||
|
||||
if ($to !== null) {
|
||||
$qb->andWhere('occurred_at <= :to')
|
||||
->setParameter('to', $to->format('Y-m-d H:i:s.uP'));
|
||||
}
|
||||
|
||||
if ($eventType !== null) {
|
||||
$qb->andWhere('event_type = :event_type')
|
||||
->setParameter('event_type', $eventType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*
|
||||
* @return list<AuditLogEntry>
|
||||
*/
|
||||
private function mapResults(array $rows): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (array $row): AuditLogEntry => AuditLogEntry::fromDatabaseRow($row),
|
||||
$rows,
|
||||
);
|
||||
}
|
||||
}
|
||||
215
backend/src/Shared/Infrastructure/Audit/AuditLogger.php
Normal file
215
backend/src/Shared/Infrastructure/Audit/AuditLogger.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Audit;
|
||||
|
||||
use App\Shared\Application\Port\AuditLogger as AuditLoggerInterface;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
|
||||
/**
|
||||
* Centralized audit logging service.
|
||||
*
|
||||
* All sensitive actions are logged to the append-only audit_log table.
|
||||
* Metadata (tenant, user, IP, correlation_id) is automatically injected.
|
||||
*
|
||||
* IMPORTANT: No PII in logs (NFR-S3). IPs/emails are hashed.
|
||||
*
|
||||
* @see Story 1.7 - T2: AuditLogger Service
|
||||
* @see FR90: Tracage actions sensibles
|
||||
* @see NFR-S3: Pas de PII dans les logs
|
||||
* @see NFR-S7: Audit trail immutable
|
||||
*/
|
||||
final readonly class AuditLogger implements AuditLoggerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
private TenantContext $tenantContext,
|
||||
private TokenStorageInterface $tokenStorage,
|
||||
private RequestStack $requestStack,
|
||||
private Clock $clock,
|
||||
private string $appSecret,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function logAuthentication(
|
||||
string $eventType,
|
||||
?UuidInterface $userId,
|
||||
array $payload,
|
||||
?string $tenantId = null,
|
||||
): void {
|
||||
$this->log(
|
||||
aggregateType: 'User',
|
||||
aggregateId: $userId,
|
||||
eventType: $eventType,
|
||||
payload: $payload,
|
||||
tenantIdOverride: $tenantId,
|
||||
userIdOverride: $userId?->toString(),
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function logDataChange(
|
||||
string $aggregateType,
|
||||
UuidInterface $aggregateId,
|
||||
string $eventType,
|
||||
array $oldValues,
|
||||
array $newValues,
|
||||
?string $reason = null,
|
||||
): void {
|
||||
$payload = [
|
||||
'old_values' => $oldValues,
|
||||
'new_values' => $newValues,
|
||||
];
|
||||
|
||||
if ($reason !== null) {
|
||||
$payload['reason'] = $reason;
|
||||
}
|
||||
|
||||
$this->log(
|
||||
aggregateType: $aggregateType,
|
||||
aggregateId: $aggregateId,
|
||||
eventType: $eventType,
|
||||
payload: $payload,
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function logExport(
|
||||
string $exportType,
|
||||
int $recordCount,
|
||||
?string $targetDescription = null,
|
||||
): void {
|
||||
$payload = [
|
||||
'export_type' => $exportType,
|
||||
'record_count' => $recordCount,
|
||||
];
|
||||
|
||||
if ($targetDescription !== null) {
|
||||
$payload['target'] = $targetDescription;
|
||||
}
|
||||
|
||||
$this->log(
|
||||
aggregateType: 'Export',
|
||||
aggregateId: null,
|
||||
eventType: 'ExportGenerated',
|
||||
payload: $payload,
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function logAccess(
|
||||
string $resourceType,
|
||||
UuidInterface $resourceId,
|
||||
array $context = [],
|
||||
): void {
|
||||
$this->log(
|
||||
aggregateType: $resourceType,
|
||||
aggregateId: $resourceId,
|
||||
eventType: 'ResourceAccessed',
|
||||
payload: $context,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Core logging method - writes to append-only audit_log table.
|
||||
*
|
||||
* @param array<string, mixed> $payload Event-specific data
|
||||
* @param string|null $tenantIdOverride Override tenant (for async handlers without TenantContext)
|
||||
* @param string|null $userIdOverride Override user (for async handlers without security token)
|
||||
*/
|
||||
private function log(
|
||||
string $aggregateType,
|
||||
?UuidInterface $aggregateId,
|
||||
string $eventType,
|
||||
array $payload,
|
||||
?string $tenantIdOverride = null,
|
||||
?string $userIdOverride = null,
|
||||
): void {
|
||||
$metadata = $this->buildMetadata($tenantIdOverride, $userIdOverride);
|
||||
|
||||
// Pass raw arrays - DBAL handles JSON encoding with 'json' type binding
|
||||
$this->connection->insert('audit_log', [
|
||||
'aggregate_type' => $aggregateType,
|
||||
'aggregate_id' => $aggregateId?->toString(),
|
||||
'event_type' => $eventType,
|
||||
'payload' => $payload,
|
||||
'metadata' => $metadata,
|
||||
'occurred_at' => $this->clock->now()->format('Y-m-d H:i:s.uP'),
|
||||
], [
|
||||
'payload' => 'json',
|
||||
'metadata' => 'json',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build metadata with automatic context injection.
|
||||
*
|
||||
* @param string|null $tenantIdOverride Override tenant from event (for async handlers)
|
||||
* @param string|null $userIdOverride Override user from event (for async handlers)
|
||||
*
|
||||
* @return array<string, string|null>
|
||||
*/
|
||||
private function buildMetadata(?string $tenantIdOverride = null, ?string $userIdOverride = null): array
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
|
||||
$metadata = [
|
||||
'correlation_id' => CorrelationIdHolder::getOrGenerate()->value(),
|
||||
'occurred_at' => $this->clock->now()->format('c'),
|
||||
];
|
||||
|
||||
// Tenant ID: use override if provided, otherwise try TenantContext
|
||||
// Override is needed for async handlers that don't have request context
|
||||
if ($tenantIdOverride !== null) {
|
||||
$metadata['tenant_id'] = $tenantIdOverride;
|
||||
} elseif ($this->tenantContext->hasTenant()) {
|
||||
$metadata['tenant_id'] = (string) $this->tenantContext->getCurrentTenantId();
|
||||
}
|
||||
|
||||
// User ID: use override if provided, otherwise try security token
|
||||
// Override is needed for async handlers that don't have security context
|
||||
if ($userIdOverride !== null) {
|
||||
$metadata['user_id'] = $userIdOverride;
|
||||
} else {
|
||||
$token = $this->tokenStorage->getToken();
|
||||
if ($token !== null) {
|
||||
$user = $token->getUser();
|
||||
if ($user !== null && method_exists($user, 'userId')) {
|
||||
$metadata['user_id'] = $user->userId();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IP and User-Agent (hashed for privacy)
|
||||
if ($request !== null) {
|
||||
$ip = $request->getClientIp();
|
||||
if ($ip !== null) {
|
||||
$metadata['ip_hash'] = $this->hashValue($ip);
|
||||
}
|
||||
|
||||
$userAgent = $request->headers->get('User-Agent');
|
||||
if ($userAgent !== null) {
|
||||
$metadata['user_agent_hash'] = hash('sha256', $userAgent);
|
||||
}
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash sensitive values (IP, email) using app secret.
|
||||
* Allows correlation without storing PII.
|
||||
*/
|
||||
private function hashValue(string $value): string
|
||||
{
|
||||
return hash('sha256', $value . $this->appSecret);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Audit;
|
||||
|
||||
use App\Shared\Domain\CorrelationId;
|
||||
|
||||
/**
|
||||
* Thread-local storage for correlation ID.
|
||||
*
|
||||
* Allows any part of the application to access the current request's
|
||||
* correlation ID for audit logging and distributed tracing.
|
||||
*
|
||||
* For HTTP requests, the CorrelationIdMiddleware sets the ID.
|
||||
* For async handlers and CRON jobs, getOrGenerate() auto-creates one.
|
||||
*
|
||||
* NOTE: Static methods are used here because PHP lacks native thread-local
|
||||
* storage (unlike Java's ThreadLocal). This is an accepted exception to the
|
||||
* "no static" rule - see project-context.md "Nuance PHP 8.5 sur No Static".
|
||||
*
|
||||
* @see Story 1.7 - T3: Correlation ID
|
||||
*/
|
||||
final class CorrelationIdHolder
|
||||
{
|
||||
private static ?CorrelationId $correlationId = null;
|
||||
|
||||
public static function set(CorrelationId $correlationId): void
|
||||
{
|
||||
self::$correlationId = $correlationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current correlation ID, or null if not set.
|
||||
*
|
||||
* Use getOrGenerate() when you need a correlation ID guaranteed.
|
||||
*/
|
||||
public static function get(): ?CorrelationId
|
||||
{
|
||||
return self::$correlationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current correlation ID, or generate one if not set.
|
||||
*
|
||||
* Use this in contexts without HTTP request (async handlers, CRON jobs)
|
||||
* to ensure traceability is never broken.
|
||||
*/
|
||||
public static function getOrGenerate(): CorrelationId
|
||||
{
|
||||
if (self::$correlationId === null) {
|
||||
self::$correlationId = CorrelationId::generate();
|
||||
}
|
||||
|
||||
return self::$correlationId;
|
||||
}
|
||||
|
||||
public static function clear(): void
|
||||
{
|
||||
self::$correlationId = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Audit\Handler;
|
||||
|
||||
use App\Administration\Domain\Event\CompteBloqueTemporairement;
|
||||
use App\Administration\Domain\Event\ConnexionEchouee;
|
||||
use App\Administration\Domain\Event\ConnexionReussie;
|
||||
use App\Administration\Domain\Event\MotDePasseChange;
|
||||
use App\Shared\Application\Port\AuditLogger;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Handles authentication events for audit logging.
|
||||
*
|
||||
* Writes to append-only audit_log table via AuditLogger.
|
||||
* All PII (emails, IPs) are hashed before storage.
|
||||
*
|
||||
* @see Story 1.7 - T4: Listeners Authentification
|
||||
* @see AC1: Tracage authentification
|
||||
*/
|
||||
final readonly class AuditAuthenticationHandler
|
||||
{
|
||||
public function __construct(
|
||||
private AuditLogger $auditLogger,
|
||||
private string $appSecret,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* T4.1: Successful login.
|
||||
*/
|
||||
#[AsMessageHandler]
|
||||
public function handleConnexionReussie(ConnexionReussie $event): void
|
||||
{
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'ConnexionReussie',
|
||||
userId: Uuid::fromString($event->userId),
|
||||
payload: [
|
||||
'email_hash' => $this->hashEmail($event->email),
|
||||
'result' => 'success',
|
||||
'method' => 'password',
|
||||
],
|
||||
tenantId: (string) $event->tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* T4.2: Failed login.
|
||||
*/
|
||||
#[AsMessageHandler]
|
||||
public function handleConnexionEchouee(ConnexionEchouee $event): void
|
||||
{
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'ConnexionEchouee',
|
||||
userId: null, // No user ID for failed logins
|
||||
payload: [
|
||||
'email_hash' => $this->hashEmail($event->email),
|
||||
'result' => 'failure',
|
||||
'reason' => $event->reason,
|
||||
],
|
||||
tenantId: $event->tenantId !== null ? (string) $event->tenantId : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* T4.3: Account temporarily locked.
|
||||
*/
|
||||
#[AsMessageHandler]
|
||||
public function handleCompteBloqueTemporairement(CompteBloqueTemporairement $event): void
|
||||
{
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'CompteBloqueTemporairement',
|
||||
userId: null, // No user ID - we only have email
|
||||
payload: [
|
||||
'email_hash' => $this->hashEmail($event->email),
|
||||
'blocked_for_seconds' => $event->blockedForSeconds,
|
||||
'failed_attempts' => $event->failedAttempts,
|
||||
],
|
||||
tenantId: $event->tenantId !== null ? (string) $event->tenantId : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* T4.4: Password changed (via reset or update).
|
||||
*/
|
||||
#[AsMessageHandler]
|
||||
public function handleMotDePasseChange(MotDePasseChange $event): void
|
||||
{
|
||||
$this->auditLogger->logAuthentication(
|
||||
eventType: 'MotDePasseChange',
|
||||
userId: Uuid::fromString($event->userId),
|
||||
payload: [
|
||||
'email_hash' => $this->hashEmail($event->email),
|
||||
],
|
||||
tenantId: (string) $event->tenantId,
|
||||
);
|
||||
}
|
||||
|
||||
private function hashEmail(string $email): string
|
||||
{
|
||||
return hash('sha256', strtolower($email) . $this->appSecret);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Console;
|
||||
|
||||
use App\Shared\Domain\Clock;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Archives audit log entries older than 5 years.
|
||||
*
|
||||
* Entries are MOVED (not copied) to audit_log_archive table.
|
||||
* This preserves immutability while managing table size.
|
||||
*
|
||||
* WARNING: This command temporarily disables PostgreSQL rules via ALTER TABLE,
|
||||
* which acquires an AccessExclusiveLock. Schedule during LOW TRAFFIC periods only.
|
||||
*
|
||||
* Run via CRON: 0 2 * * 0 php bin/console app:audit:archive
|
||||
*
|
||||
* @see Story 1.7 - T8: Archivage
|
||||
* @see NFR-C5: Conservation 5 ans actif, puis archive
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:audit:archive',
|
||||
description: 'Archive audit log entries older than 5 years',
|
||||
)]
|
||||
final class ArchiveAuditLogsCommand extends Command
|
||||
{
|
||||
private const int DEFAULT_RETENTION_YEARS = 5;
|
||||
private const int DEFAULT_BATCH_SIZE = 1000;
|
||||
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly Clock $clock,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption(
|
||||
'retention-years',
|
||||
'r',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Number of years to keep in active table',
|
||||
(string) self::DEFAULT_RETENTION_YEARS,
|
||||
)
|
||||
->addOption(
|
||||
'batch-size',
|
||||
'b',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Number of records to process per batch',
|
||||
(string) self::DEFAULT_BATCH_SIZE,
|
||||
)
|
||||
->addOption(
|
||||
'dry-run',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Simulate without making changes',
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
/** @var string $retentionYearsOption */
|
||||
$retentionYearsOption = $input->getOption('retention-years');
|
||||
$retentionYears = (int) $retentionYearsOption;
|
||||
|
||||
if ($retentionYears < 1) {
|
||||
$io->error('Retention years must be a positive integer.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
/** @var string $batchSizeOption */
|
||||
$batchSizeOption = $input->getOption('batch-size');
|
||||
$batchSize = (int) $batchSizeOption;
|
||||
|
||||
if ($batchSize < 1) {
|
||||
$io->error('Batch size must be a positive integer.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
$cutoffDate = $this->clock->now()->modify("-{$retentionYears} years");
|
||||
$io->title('Audit Log Archival');
|
||||
$io->info(sprintf(
|
||||
'Archiving entries older than %s (%d years retention)',
|
||||
$cutoffDate->format('Y-m-d H:i:s'),
|
||||
$retentionYears,
|
||||
));
|
||||
|
||||
if ($dryRun) {
|
||||
$io->warning('DRY RUN MODE - No changes will be made');
|
||||
}
|
||||
|
||||
// Count entries to archive
|
||||
$countSql = 'SELECT COUNT(*) FROM audit_log WHERE occurred_at < :cutoff';
|
||||
/** @var string|int|false $countResult */
|
||||
$countResult = $this->connection->fetchOne($countSql, [
|
||||
'cutoff' => $cutoffDate->format('Y-m-d H:i:s.uP'),
|
||||
]);
|
||||
$totalCount = (int) $countResult;
|
||||
|
||||
if ($totalCount === 0) {
|
||||
$io->success('No entries to archive.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->info(sprintf('Found %d entries to archive', $totalCount));
|
||||
|
||||
if ($dryRun) {
|
||||
$io->success(sprintf('DRY RUN: Would archive %d entries', $totalCount));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Archive in batches
|
||||
$progressBar = $io->createProgressBar($totalCount);
|
||||
$progressBar->start();
|
||||
$archivedCount = 0;
|
||||
|
||||
do {
|
||||
$batchCount = $this->archiveBatch($cutoffDate, $batchSize);
|
||||
$archivedCount += $batchCount;
|
||||
$progressBar->advance($batchCount);
|
||||
} while ($batchCount === $batchSize);
|
||||
|
||||
$progressBar->finish();
|
||||
$io->newLine(2);
|
||||
|
||||
$io->success(sprintf(
|
||||
'Successfully archived %d audit log entries',
|
||||
$archivedCount,
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a batch of entries using PostgreSQL function.
|
||||
*
|
||||
* The archive_audit_entries() function is SECURITY DEFINER and can
|
||||
* bypass the audit_no_delete rule. This is the ONLY way to delete
|
||||
* from audit_log - ensuring immutability for normal operations.
|
||||
*/
|
||||
private function archiveBatch(DateTimeImmutable $cutoffDate, int $batchSize): int
|
||||
{
|
||||
// Call the PostgreSQL function that handles INSERT + DELETE atomically
|
||||
// The function uses SECURITY DEFINER to bypass the delete rule
|
||||
$sql = 'SELECT archive_audit_entries(:cutoff, :batch_size)';
|
||||
|
||||
/** @var int|string|false $result */
|
||||
$result = $this->connection->fetchOne($sql, [
|
||||
'cutoff' => $cutoffDate->format('Y-m-d H:i:s.uP'),
|
||||
'batch_size' => $batchSize,
|
||||
]);
|
||||
|
||||
return (int) $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Messenger;
|
||||
|
||||
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
|
||||
use Symfony\Component\Messenger\Middleware\StackInterface;
|
||||
|
||||
/**
|
||||
* Adds correlation ID stamp to outgoing messages.
|
||||
*
|
||||
* When a message is dispatched during an HTTP request, this middleware
|
||||
* captures the current correlation ID and attaches it as a stamp.
|
||||
* The receiving worker will then use this stamp to restore the correlation ID.
|
||||
*
|
||||
* @see Story 1.7 - T3: Correlation ID
|
||||
*/
|
||||
final readonly class AddCorrelationIdStampMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function handle(Envelope $envelope, StackInterface $stack): Envelope
|
||||
{
|
||||
// Only add stamp if not already present and we have a correlation ID
|
||||
if ($envelope->last(CorrelationIdStamp::class) === null) {
|
||||
$correlationId = CorrelationIdHolder::get();
|
||||
if ($correlationId !== null) {
|
||||
$envelope = $envelope->with(new CorrelationIdStamp($correlationId->value()));
|
||||
}
|
||||
}
|
||||
|
||||
return $stack->next()->handle($envelope, $stack);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Messenger;
|
||||
|
||||
use App\Shared\Domain\CorrelationId;
|
||||
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
|
||||
use Symfony\Component\Messenger\Middleware\StackInterface;
|
||||
|
||||
/**
|
||||
* Messenger middleware for correlation ID propagation in async workers.
|
||||
*
|
||||
* - Extracts correlation ID from message stamp if present (propagated from HTTP request)
|
||||
* - Otherwise uses existing ID (synchronous dispatch) or generates new one (async worker)
|
||||
* - Only clears after handling in async context to prevent leakage between messages
|
||||
*
|
||||
* IMPORTANT: We must NOT clear the correlation ID during synchronous dispatch
|
||||
* within an HTTP request, as it would break traceability for the rest of the request.
|
||||
*
|
||||
* @see Story 1.7 - T3: Correlation ID
|
||||
*/
|
||||
final readonly class CorrelationIdMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function handle(Envelope $envelope, StackInterface $stack): Envelope
|
||||
{
|
||||
// Check if we already have a correlation ID (from HTTP middleware)
|
||||
$existingId = CorrelationIdHolder::get();
|
||||
|
||||
// Extract correlation ID from stamp if present (async message from HTTP)
|
||||
$stamp = $envelope->last(CorrelationIdStamp::class);
|
||||
|
||||
if ($stamp instanceof CorrelationIdStamp) {
|
||||
// Async worker receiving message from HTTP - use stamp's ID
|
||||
$correlationId = CorrelationId::fromString($stamp->correlationId);
|
||||
CorrelationIdHolder::set($correlationId);
|
||||
$shouldClear = true; // Clear after async handling
|
||||
} elseif ($existingId !== null) {
|
||||
// Synchronous dispatch within HTTP request - keep existing ID
|
||||
$shouldClear = false; // Don't clear - HTTP middleware will handle it
|
||||
} else {
|
||||
// Async worker with no stamp - generate new ID for this message
|
||||
$correlationId = CorrelationId::generate();
|
||||
CorrelationIdHolder::set($correlationId);
|
||||
$shouldClear = true; // Clear after handling
|
||||
}
|
||||
|
||||
try {
|
||||
return $stack->next()->handle($envelope, $stack);
|
||||
} finally {
|
||||
// Only clear in async context to prevent leakage between messages
|
||||
// Don't clear during synchronous dispatch - HTTP middleware handles that
|
||||
if ($shouldClear) {
|
||||
CorrelationIdHolder::clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Messenger;
|
||||
|
||||
use Symfony\Component\Messenger\Stamp\StampInterface;
|
||||
|
||||
/**
|
||||
* Stamp to propagate correlation ID from HTTP request to async message handlers.
|
||||
*
|
||||
* Added automatically by AddCorrelationIdStampMiddleware when dispatching messages.
|
||||
*
|
||||
* @see Story 1.7 - T3: Correlation ID
|
||||
*/
|
||||
final readonly class CorrelationIdStamp implements StampInterface
|
||||
{
|
||||
public function __construct(
|
||||
public string $correlationId,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\Middleware;
|
||||
|
||||
use App\Shared\Domain\CorrelationId;
|
||||
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
use Symfony\Component\HttpKernel\Event\TerminateEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* Generates or propagates correlation ID for request tracing.
|
||||
*
|
||||
* - If X-Correlation-Id header exists, uses it (distributed tracing)
|
||||
* - Otherwise generates a new UUID v7
|
||||
* - Stores in CorrelationIdHolder for access anywhere in the request
|
||||
* - Adds X-Correlation-Id header to response
|
||||
* - Clears on request termination (and defensively on exception)
|
||||
*
|
||||
* @see Story 1.7 - T3: Correlation ID
|
||||
*/
|
||||
final readonly class CorrelationIdMiddleware
|
||||
{
|
||||
public const string HEADER_NAME = 'X-Correlation-Id';
|
||||
|
||||
#[AsEventListener(event: KernelEvents::REQUEST, priority: 255)]
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Defensive cleanup: if previous request didn't terminate properly
|
||||
// (edge case: fatal error, script termination), clear stale ID
|
||||
CorrelationIdHolder::clear();
|
||||
|
||||
$request = $event->getRequest();
|
||||
$existingId = $request->headers->get(self::HEADER_NAME);
|
||||
|
||||
$correlationId = $this->parseOrGenerate($existingId);
|
||||
|
||||
CorrelationIdHolder::set($correlationId);
|
||||
|
||||
// Also store in request attributes for easy access
|
||||
$request->attributes->set('correlation_id', $correlationId);
|
||||
}
|
||||
|
||||
#[AsEventListener(event: KernelEvents::RESPONSE, priority: -255)]
|
||||
public function onKernelResponse(ResponseEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$correlationId = CorrelationIdHolder::get();
|
||||
if ($correlationId !== null) {
|
||||
$event->getResponse()->headers->set(
|
||||
self::HEADER_NAME,
|
||||
$correlationId->value(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store correlation ID in exception for error logging before cleanup.
|
||||
*
|
||||
* Priority -255: run after other exception handlers that might need the ID.
|
||||
*/
|
||||
#[AsEventListener(event: KernelEvents::EXCEPTION, priority: -255)]
|
||||
public function onKernelException(ExceptionEvent $event): void
|
||||
{
|
||||
$correlationId = CorrelationIdHolder::get();
|
||||
if ($correlationId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add correlation ID to exception context if possible
|
||||
$exception = $event->getThrowable();
|
||||
if (method_exists($exception, 'setCorrelationId')) {
|
||||
$exception->setCorrelationId($correlationId->value());
|
||||
}
|
||||
}
|
||||
|
||||
#[AsEventListener(event: KernelEvents::TERMINATE)]
|
||||
public function onKernelTerminate(TerminateEvent $event): void
|
||||
{
|
||||
CorrelationIdHolder::clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse existing correlation ID or generate new one if invalid.
|
||||
*
|
||||
* We're lenient with malformed input - just generate a fresh ID.
|
||||
*/
|
||||
private function parseOrGenerate(?string $existingId): CorrelationId
|
||||
{
|
||||
if ($existingId === null) {
|
||||
return CorrelationId::generate();
|
||||
}
|
||||
|
||||
try {
|
||||
return CorrelationId::fromString($existingId);
|
||||
} catch (InvalidArgumentException) {
|
||||
return CorrelationId::generate();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Administration\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* [P0] Functional tests for account activation endpoints.
|
||||
*
|
||||
* Verifies:
|
||||
* - Token info endpoint accessibility (public)
|
||||
* - Activate endpoint accessibility (public)
|
||||
* - Token validation
|
||||
* - Password requirements validation
|
||||
*/
|
||||
final class ActivationEndpointsTest extends ApiTestCase
|
||||
{
|
||||
protected static ?bool $alwaysBootKernel = true;
|
||||
|
||||
#[Test]
|
||||
public function activationTokenInfoEndpointIsAccessibleWithoutAuthentication(): void
|
||||
{
|
||||
// GIVEN: No authentication
|
||||
$client = static::createClient();
|
||||
|
||||
// WHEN: Requesting token info for an invalid token
|
||||
$response = $client->request('GET', '/api/activation-tokens/550e8400-e29b-41d4-a716-446655440000', [
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
],
|
||||
]);
|
||||
|
||||
// THEN: Returns 404 (not 401) because endpoint is public
|
||||
// Invalid token returns 404 Not Found
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activateEndpointIsAccessibleWithoutAuthentication(): void
|
||||
{
|
||||
// GIVEN: No authentication
|
||||
$client = static::createClient();
|
||||
|
||||
// WHEN: Attempting to activate with invalid token
|
||||
$response = $client->request('POST', '/api/activate', [
|
||||
'json' => [
|
||||
'tokenValue' => '550e8400-e29b-41d4-a716-446655440000',
|
||||
'password' => 'ValidPassword123!',
|
||||
],
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
],
|
||||
]);
|
||||
|
||||
// THEN: Returns 404 (token not found) not 401 (unauthorized)
|
||||
// The endpoint is accessible without JWT, it validates the token's existence
|
||||
self::assertNotEquals(401, $response->getStatusCode(), 'Activation endpoint should be accessible without JWT');
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activateEndpointValidatesPasswordRequirements(): void
|
||||
{
|
||||
// GIVEN: No authentication
|
||||
$client = static::createClient();
|
||||
|
||||
// WHEN: Attempting activation with weak password
|
||||
$response = $client->request('POST', '/api/activate', [
|
||||
'json' => [
|
||||
'tokenValue' => '550e8400-e29b-41d4-a716-446655440000',
|
||||
'password' => 'weak', // Too short, no uppercase, no number, no special char
|
||||
],
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
],
|
||||
]);
|
||||
|
||||
// THEN: Returns 422 Unprocessable Entity for validation errors
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function activateEndpointRequiresTokenAndPassword(): void
|
||||
{
|
||||
// GIVEN: No authentication
|
||||
$client = static::createClient();
|
||||
|
||||
// WHEN: Attempting activation without required fields
|
||||
$response = $client->request('POST', '/api/activate', [
|
||||
'json' => [],
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
],
|
||||
]);
|
||||
|
||||
// THEN: Returns 422 for missing required fields
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Administration\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* [P0] Functional tests for the refresh token endpoint.
|
||||
*
|
||||
* Verifies:
|
||||
* - Endpoint accessibility
|
||||
* - Missing token handling (401)
|
||||
* - Invalid token handling
|
||||
* - Cookie-based authentication
|
||||
*/
|
||||
final class RefreshTokenEndpointTest extends ApiTestCase
|
||||
{
|
||||
protected static ?bool $alwaysBootKernel = true;
|
||||
|
||||
#[Test]
|
||||
public function refreshEndpointReturns401WithoutCookie(): void
|
||||
{
|
||||
// GIVEN: No refresh_token cookie
|
||||
$client = static::createClient();
|
||||
|
||||
// WHEN: Calling refresh endpoint
|
||||
$response = $client->request('POST', '/api/token/refresh', [
|
||||
'json' => [],
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
],
|
||||
]);
|
||||
|
||||
// THEN: Returns 401 Unauthorized
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function logoutEndpointIsAccessibleWithoutToken(): void
|
||||
{
|
||||
// GIVEN: No authentication
|
||||
$client = static::createClient();
|
||||
|
||||
// WHEN: Calling logout endpoint
|
||||
$response = $client->request('POST', '/api/token/logout', [
|
||||
'json' => [],
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
],
|
||||
]);
|
||||
|
||||
// THEN: Returns 200 OK (idempotent - no token to invalidate)
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function logoutEndpointClearsCookies(): void
|
||||
{
|
||||
// GIVEN: A client
|
||||
$client = static::createClient();
|
||||
|
||||
// WHEN: Calling logout
|
||||
$response = $client->request('POST', '/api/token/logout', [
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
'Cookie' => 'refresh_token=some-token-value',
|
||||
],
|
||||
]);
|
||||
|
||||
// THEN: Response sets expired cookies
|
||||
$setCookieHeaders = $response->getHeaders(false)['set-cookie'] ?? [];
|
||||
$this->assertNotEmpty($setCookieHeaders);
|
||||
|
||||
$hasClearedCookie = false;
|
||||
foreach ($setCookieHeaders as $cookie) {
|
||||
if (str_contains($cookie, 'refresh_token=') && str_contains($cookie, 'expires=')) {
|
||||
$hasClearedCookie = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->assertTrue($hasClearedCookie, 'Should set expired refresh_token cookie');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function refreshEndpointWithInvalidTokenReturns401(): void
|
||||
{
|
||||
// GIVEN: An invalid/malformed token in cookie
|
||||
$client = static::createClient();
|
||||
|
||||
// WHEN: Calling refresh with invalid cookie
|
||||
$response = $client->request('POST', '/api/token/refresh', [
|
||||
'json' => [],
|
||||
'headers' => [
|
||||
'Host' => 'localhost',
|
||||
'Cookie' => 'refresh_token=invalid-token-format',
|
||||
],
|
||||
]);
|
||||
|
||||
// THEN: Returns 401 Unauthorized
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Api\Controller;
|
||||
|
||||
use App\Administration\Domain\Event\Deconnexion;
|
||||
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
|
||||
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\RefreshTokenRepository;
|
||||
use App\Administration\Domain\Repository\SessionRepository;
|
||||
use App\Administration\Infrastructure\Api\Controller\LogoutController;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* [P0] Tests for LogoutController - critical session invalidation.
|
||||
*
|
||||
* Verifies:
|
||||
* - Token family invalidation on logout
|
||||
* - Session deletion
|
||||
* - Cookie clearing
|
||||
* - Deconnexion event dispatch
|
||||
* - Graceful handling of missing/malformed tokens
|
||||
*/
|
||||
final class LogoutControllerTest extends TestCase
|
||||
{
|
||||
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string IP_ADDRESS = '192.168.1.100';
|
||||
private const string USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)';
|
||||
|
||||
private RefreshTokenRepository $refreshTokenRepository;
|
||||
private SessionRepository $sessionRepository;
|
||||
private Clock $clock;
|
||||
/** @var DomainEvent[] */
|
||||
private array $dispatchedEvents = [];
|
||||
private LogoutController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->refreshTokenRepository = $this->createMock(RefreshTokenRepository::class);
|
||||
$this->sessionRepository = $this->createMock(SessionRepository::class);
|
||||
|
||||
$this->clock = new class implements Clock {
|
||||
#[Override]
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-01-28 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->dispatchedEvents = [];
|
||||
$eventBus = new class($this->dispatchedEvents) implements MessageBusInterface {
|
||||
/** @param DomainEvent[] $events */
|
||||
public function __construct(private array &$events)
|
||||
{
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function dispatch(object $message, array $stamps = []): Envelope
|
||||
{
|
||||
$this->events[] = $message;
|
||||
|
||||
return new Envelope($message);
|
||||
}
|
||||
};
|
||||
|
||||
$this->controller = new LogoutController(
|
||||
$this->refreshTokenRepository,
|
||||
$this->sessionRepository,
|
||||
$eventBus,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itInvalidatesTokenFamilyOnLogout(): void
|
||||
{
|
||||
// GIVEN: A request with a valid refresh token cookie
|
||||
$refreshToken = $this->createRefreshToken();
|
||||
$tokenString = $refreshToken->toTokenString();
|
||||
|
||||
$request = Request::create('/api/token/logout', 'POST', [], [
|
||||
'refresh_token' => $tokenString,
|
||||
], [], [
|
||||
'REMOTE_ADDR' => self::IP_ADDRESS,
|
||||
'HTTP_USER_AGENT' => self::USER_AGENT,
|
||||
]);
|
||||
|
||||
$this->refreshTokenRepository
|
||||
->expects($this->once())
|
||||
->method('find')
|
||||
->with($refreshToken->id)
|
||||
->willReturn($refreshToken);
|
||||
|
||||
// THEN: Family should be invalidated
|
||||
$this->refreshTokenRepository
|
||||
->expects($this->once())
|
||||
->method('invalidateFamily')
|
||||
->with($refreshToken->familyId);
|
||||
|
||||
// WHEN: Logout is invoked
|
||||
$response = ($this->controller)($request);
|
||||
|
||||
// THEN: Returns success
|
||||
$this->assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeletesSessionOnLogout(): void
|
||||
{
|
||||
// GIVEN: A valid refresh token
|
||||
$refreshToken = $this->createRefreshToken();
|
||||
|
||||
$request = Request::create('/api/token/logout', 'POST', [], [
|
||||
'refresh_token' => $refreshToken->toTokenString(),
|
||||
], [], [
|
||||
'REMOTE_ADDR' => self::IP_ADDRESS,
|
||||
'HTTP_USER_AGENT' => self::USER_AGENT,
|
||||
]);
|
||||
|
||||
$this->refreshTokenRepository
|
||||
->method('find')
|
||||
->willReturn($refreshToken);
|
||||
|
||||
// THEN: Session should be deleted
|
||||
$this->sessionRepository
|
||||
->expects($this->once())
|
||||
->method('delete')
|
||||
->with($refreshToken->familyId);
|
||||
|
||||
// WHEN: Logout is invoked
|
||||
($this->controller)($request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDispatchesDeconnexionEvent(): void
|
||||
{
|
||||
// GIVEN: A valid refresh token
|
||||
$refreshToken = $this->createRefreshToken();
|
||||
|
||||
$request = Request::create('/api/token/logout', 'POST', [], [
|
||||
'refresh_token' => $refreshToken->toTokenString(),
|
||||
], [], [
|
||||
'REMOTE_ADDR' => self::IP_ADDRESS,
|
||||
'HTTP_USER_AGENT' => self::USER_AGENT,
|
||||
]);
|
||||
|
||||
$this->refreshTokenRepository
|
||||
->method('find')
|
||||
->willReturn($refreshToken);
|
||||
|
||||
// WHEN: Logout is invoked
|
||||
($this->controller)($request);
|
||||
|
||||
// THEN: Deconnexion event is dispatched with correct data
|
||||
$logoutEvents = array_filter(
|
||||
$this->dispatchedEvents,
|
||||
static fn ($e) => $e instanceof Deconnexion,
|
||||
);
|
||||
$this->assertCount(1, $logoutEvents);
|
||||
|
||||
$event = reset($logoutEvents);
|
||||
$this->assertSame((string) $refreshToken->userId, $event->userId);
|
||||
$this->assertSame((string) $refreshToken->familyId, $event->familyId);
|
||||
$this->assertSame(self::IP_ADDRESS, $event->ipAddress);
|
||||
$this->assertSame(self::USER_AGENT, $event->userAgent);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itClearsCookiesOnLogout(): void
|
||||
{
|
||||
// GIVEN: A valid refresh token
|
||||
$refreshToken = $this->createRefreshToken();
|
||||
|
||||
$request = Request::create('/api/token/logout', 'POST', [], [
|
||||
'refresh_token' => $refreshToken->toTokenString(),
|
||||
], [], [
|
||||
'REMOTE_ADDR' => self::IP_ADDRESS,
|
||||
'HTTP_USER_AGENT' => self::USER_AGENT,
|
||||
]);
|
||||
|
||||
$this->refreshTokenRepository
|
||||
->method('find')
|
||||
->willReturn($refreshToken);
|
||||
|
||||
// WHEN: Logout is invoked
|
||||
$response = ($this->controller)($request);
|
||||
|
||||
// THEN: Cookies are cleared (expired)
|
||||
$cookies = $response->headers->getCookies();
|
||||
$this->assertCount(2, $cookies); // /api and /api/token (legacy)
|
||||
|
||||
foreach ($cookies as $cookie) {
|
||||
$this->assertSame('refresh_token', $cookie->getName());
|
||||
$this->assertSame('', $cookie->getValue());
|
||||
$this->assertTrue($cookie->isCleared()); // Expiry in the past
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesMissingCookieGracefully(): void
|
||||
{
|
||||
// GIVEN: A request without refresh_token cookie
|
||||
$request = Request::create('/api/token/logout', 'POST', [], [], [], [
|
||||
'REMOTE_ADDR' => self::IP_ADDRESS,
|
||||
'HTTP_USER_AGENT' => self::USER_AGENT,
|
||||
]);
|
||||
|
||||
// THEN: No repository operations
|
||||
$this->refreshTokenRepository
|
||||
->expects($this->never())
|
||||
->method('find');
|
||||
|
||||
$this->refreshTokenRepository
|
||||
->expects($this->never())
|
||||
->method('invalidateFamily');
|
||||
|
||||
$this->sessionRepository
|
||||
->expects($this->never())
|
||||
->method('delete');
|
||||
|
||||
// WHEN: Logout is invoked
|
||||
$response = ($this->controller)($request);
|
||||
|
||||
// THEN: Still returns success (idempotent)
|
||||
$this->assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
|
||||
// THEN: Cookies are still cleared
|
||||
$this->assertNotEmpty($response->headers->getCookies());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesMalformedTokenGracefully(): void
|
||||
{
|
||||
// GIVEN: A request with malformed token
|
||||
$request = Request::create('/api/token/logout', 'POST', [], [
|
||||
'refresh_token' => 'malformed-token-not-base64',
|
||||
], [], [
|
||||
'REMOTE_ADDR' => self::IP_ADDRESS,
|
||||
'HTTP_USER_AGENT' => self::USER_AGENT,
|
||||
]);
|
||||
|
||||
// THEN: No repository operations (exception caught)
|
||||
$this->refreshTokenRepository
|
||||
->expects($this->never())
|
||||
->method('invalidateFamily');
|
||||
|
||||
// WHEN: Logout is invoked
|
||||
$response = ($this->controller)($request);
|
||||
|
||||
// THEN: Still returns success
|
||||
$this->assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesNonExistentTokenGracefully(): void
|
||||
{
|
||||
// GIVEN: A valid token format but not in database
|
||||
$refreshToken = $this->createRefreshToken();
|
||||
|
||||
$request = Request::create('/api/token/logout', 'POST', [], [
|
||||
'refresh_token' => $refreshToken->toTokenString(),
|
||||
], [], [
|
||||
'REMOTE_ADDR' => self::IP_ADDRESS,
|
||||
'HTTP_USER_AGENT' => self::USER_AGENT,
|
||||
]);
|
||||
|
||||
$this->refreshTokenRepository
|
||||
->method('find')
|
||||
->willReturn(null);
|
||||
|
||||
// THEN: No invalidation attempted
|
||||
$this->refreshTokenRepository
|
||||
->expects($this->never())
|
||||
->method('invalidateFamily');
|
||||
|
||||
// WHEN: Logout is invoked
|
||||
$response = ($this->controller)($request);
|
||||
|
||||
// THEN: Still returns success (idempotent)
|
||||
$this->assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
}
|
||||
|
||||
private function createRefreshToken(): RefreshToken
|
||||
{
|
||||
return RefreshToken::create(
|
||||
userId: UserId::fromString(self::USER_ID),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
deviceFingerprint: DeviceFingerprint::fromRequest(self::USER_AGENT, self::IP_ADDRESS),
|
||||
issuedAt: $this->clock->now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Model\RefreshToken\DeviceFingerprint;
|
||||
use App\Administration\Domain\Model\RefreshToken\RefreshToken;
|
||||
use App\Administration\Domain\Model\RefreshToken\TokenFamilyId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryRefreshTokenRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* [P1] Tests for InMemoryRefreshTokenRepository.
|
||||
*
|
||||
* Verifies:
|
||||
* - Token save and retrieval
|
||||
* - Token deletion
|
||||
* - Family invalidation (all tokens in family)
|
||||
* - User invalidation (all families for user)
|
||||
* - Index maintenance
|
||||
*/
|
||||
final class InMemoryRefreshTokenRepositoryTest extends TestCase
|
||||
{
|
||||
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string USER_ID_2 = '550e8400-e29b-41d4-a716-446655440003';
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
private InMemoryRefreshTokenRepository $repository;
|
||||
private DateTimeImmutable $now;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = new InMemoryRefreshTokenRepository();
|
||||
$this->now = new DateTimeImmutable('2026-01-28 10:00:00');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSavesAndRetrievesToken(): void
|
||||
{
|
||||
// GIVEN: A refresh token
|
||||
$token = $this->createToken(self::USER_ID);
|
||||
|
||||
// WHEN: Token is saved
|
||||
$this->repository->save($token);
|
||||
|
||||
// THEN: Token can be retrieved by ID
|
||||
$found = $this->repository->find($token->id);
|
||||
$this->assertNotNull($found);
|
||||
$this->assertSame((string) $token->id, (string) $found->id);
|
||||
$this->assertSame((string) $token->familyId, (string) $found->familyId);
|
||||
$this->assertSame((string) $token->userId, (string) $found->userId);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itReturnsNullForNonExistentToken(): void
|
||||
{
|
||||
// GIVEN: Empty repository
|
||||
|
||||
// WHEN: Searching for non-existent token
|
||||
$token = $this->createToken(self::USER_ID);
|
||||
$found = $this->repository->find($token->id);
|
||||
|
||||
// THEN: Returns null
|
||||
$this->assertNull($found);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDeletesTokenById(): void
|
||||
{
|
||||
// GIVEN: A saved token
|
||||
$token = $this->createToken(self::USER_ID);
|
||||
$this->repository->save($token);
|
||||
|
||||
// WHEN: Token is deleted
|
||||
$this->repository->delete($token->id);
|
||||
|
||||
// THEN: Token is no longer found
|
||||
$found = $this->repository->find($token->id);
|
||||
$this->assertNull($found);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itInvalidatesEntireFamily(): void
|
||||
{
|
||||
// GIVEN: Multiple tokens in the same family
|
||||
$token1 = $this->createToken(self::USER_ID);
|
||||
$token2 = $this->rotateToken($token1);
|
||||
$token3 = $this->rotateToken($token2);
|
||||
|
||||
$this->repository->save($token1);
|
||||
$this->repository->save($token2);
|
||||
$this->repository->save($token3);
|
||||
|
||||
// WHEN: Family is invalidated
|
||||
$this->repository->invalidateFamily($token1->familyId);
|
||||
|
||||
// THEN: All tokens in family are deleted
|
||||
$this->assertNull($this->repository->find($token1->id));
|
||||
$this->assertNull($this->repository->find($token2->id));
|
||||
$this->assertNull($this->repository->find($token3->id));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDoesNotAffectOtherFamilies(): void
|
||||
{
|
||||
// GIVEN: Tokens in different families
|
||||
$token1 = $this->createToken(self::USER_ID);
|
||||
$token2 = $this->createToken(self::USER_ID); // Different family (new login session)
|
||||
|
||||
$this->repository->save($token1);
|
||||
$this->repository->save($token2);
|
||||
|
||||
// WHEN: One family is invalidated
|
||||
$this->repository->invalidateFamily($token1->familyId);
|
||||
|
||||
// THEN: Other family is intact
|
||||
$this->assertNull($this->repository->find($token1->id));
|
||||
$this->assertNotNull($this->repository->find($token2->id));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itInvalidatesAllFamiliesForUser(): void
|
||||
{
|
||||
// GIVEN: Multiple families for the same user (multiple devices)
|
||||
$token1 = $this->createToken(self::USER_ID);
|
||||
$token2 = $this->createToken(self::USER_ID);
|
||||
$token3 = $this->createToken(self::USER_ID);
|
||||
|
||||
$this->repository->save($token1);
|
||||
$this->repository->save($token2);
|
||||
$this->repository->save($token3);
|
||||
|
||||
// All belong to same user but different families
|
||||
$userId = UserId::fromString(self::USER_ID);
|
||||
|
||||
// Verify user has active sessions
|
||||
$this->assertTrue($this->repository->hasActiveSessionsForUser($userId));
|
||||
|
||||
// WHEN: All tokens for user are invalidated
|
||||
$this->repository->invalidateAllForUser($userId);
|
||||
|
||||
// THEN: No sessions remain for user
|
||||
$this->assertFalse($this->repository->hasActiveSessionsForUser($userId));
|
||||
$this->assertNull($this->repository->find($token1->id));
|
||||
$this->assertNull($this->repository->find($token2->id));
|
||||
$this->assertNull($this->repository->find($token3->id));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDoesNotAffectOtherUsers(): void
|
||||
{
|
||||
// GIVEN: Tokens for different users
|
||||
$token1 = $this->createToken(self::USER_ID);
|
||||
$token2 = $this->createToken(self::USER_ID_2);
|
||||
|
||||
$this->repository->save($token1);
|
||||
$this->repository->save($token2);
|
||||
|
||||
// WHEN: First user's tokens are invalidated
|
||||
$this->repository->invalidateAllForUser(UserId::fromString(self::USER_ID));
|
||||
|
||||
// THEN: Second user's token is intact
|
||||
$this->assertNull($this->repository->find($token1->id));
|
||||
$this->assertNotNull($this->repository->find($token2->id));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itTracksActiveSessionsForUser(): void
|
||||
{
|
||||
// GIVEN: No tokens for user
|
||||
$userId = UserId::fromString(self::USER_ID);
|
||||
$this->assertFalse($this->repository->hasActiveSessionsForUser($userId));
|
||||
|
||||
// WHEN: Token is saved
|
||||
$token = $this->createToken(self::USER_ID);
|
||||
$this->repository->save($token);
|
||||
|
||||
// THEN: User has active sessions
|
||||
$this->assertTrue($this->repository->hasActiveSessionsForUser($userId));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesInvalidationOfNonExistentFamily(): void
|
||||
{
|
||||
// GIVEN: Non-existent family ID
|
||||
$familyId = TokenFamilyId::generate();
|
||||
|
||||
// WHEN: Invalidating non-existent family
|
||||
$this->repository->invalidateFamily($familyId);
|
||||
|
||||
// THEN: No exception thrown (idempotent operation)
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesInvalidationOfNonExistentUser(): void
|
||||
{
|
||||
// GIVEN: Non-existent user ID
|
||||
$userId = UserId::fromString(self::USER_ID);
|
||||
|
||||
// WHEN: Invalidating non-existent user's tokens
|
||||
$this->repository->invalidateAllForUser($userId);
|
||||
|
||||
// THEN: No exception thrown (idempotent operation)
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itHandlesDuplicateSavesIdempotently(): void
|
||||
{
|
||||
// GIVEN: A token
|
||||
$token = $this->createToken(self::USER_ID);
|
||||
|
||||
// WHEN: Token is saved multiple times
|
||||
$this->repository->save($token);
|
||||
$this->repository->save($token);
|
||||
$this->repository->save($token);
|
||||
|
||||
// THEN: Token exists once (no duplicates in indexes)
|
||||
$found = $this->repository->find($token->id);
|
||||
$this->assertNotNull($found);
|
||||
|
||||
// Invalidating family should clean everything properly
|
||||
$this->repository->invalidateFamily($token->familyId);
|
||||
$this->assertNull($this->repository->find($token->id));
|
||||
}
|
||||
|
||||
private function createToken(string $userId): RefreshToken
|
||||
{
|
||||
return RefreshToken::create(
|
||||
userId: UserId::fromString($userId),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
deviceFingerprint: DeviceFingerprint::fromRequest('Mozilla/5.0', '192.168.1.1'),
|
||||
issuedAt: $this->now,
|
||||
);
|
||||
}
|
||||
|
||||
private function rotateToken(RefreshToken $token): RefreshToken
|
||||
{
|
||||
[$newToken, $oldToken] = $token->rotate($this->now->modify('+1 minute'));
|
||||
|
||||
return $newToken;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Infrastructure\Security;
|
||||
|
||||
use App\Administration\Application\Port\GeoLocationService;
|
||||
use App\Administration\Application\Service\RefreshTokenManager;
|
||||
use App\Administration\Domain\Event\ConnexionReussie;
|
||||
use App\Administration\Domain\Model\Session\Location;
|
||||
use App\Administration\Domain\Model\Session\Session;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\SessionRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryRefreshTokenRepository;
|
||||
use App\Administration\Infrastructure\Security\LoginSuccessHandler;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\RateLimit\LoginRateLimiterInterface;
|
||||
use DateTimeImmutable;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Event\AuthenticationSuccessEvent;
|
||||
use Override;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* [P0] Tests for LoginSuccessHandler - critical authentication flow.
|
||||
*
|
||||
* Verifies:
|
||||
* - Refresh token creation on successful login
|
||||
* - Session creation with device info and geolocation
|
||||
* - Rate limiter reset after successful login
|
||||
* - ConnexionReussie event dispatch
|
||||
* - HttpOnly cookie configuration
|
||||
*/
|
||||
final class LoginSuccessHandlerTest extends TestCase
|
||||
{
|
||||
private const string USER_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440002';
|
||||
private const string EMAIL = 'user@example.com';
|
||||
private const string IP_ADDRESS = '192.168.1.100';
|
||||
private const string USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)';
|
||||
|
||||
private InMemoryRefreshTokenRepository $refreshTokenRepository;
|
||||
private RefreshTokenManager $refreshTokenManager;
|
||||
private SessionRepository $sessionRepository;
|
||||
private GeoLocationService $geoLocationService;
|
||||
private LoginRateLimiterInterface $rateLimiter;
|
||||
private Clock $clock;
|
||||
private RequestStack $requestStack;
|
||||
/** @var DomainEvent[] */
|
||||
private array $dispatchedEvents = [];
|
||||
private LoginSuccessHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->clock = new class implements Clock {
|
||||
#[Override]
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-01-28 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->refreshTokenRepository = new InMemoryRefreshTokenRepository();
|
||||
$this->refreshTokenManager = new RefreshTokenManager(
|
||||
$this->refreshTokenRepository,
|
||||
$this->clock,
|
||||
);
|
||||
|
||||
$this->sessionRepository = $this->createMock(SessionRepository::class);
|
||||
$this->geoLocationService = $this->createMock(GeoLocationService::class);
|
||||
$this->rateLimiter = $this->createMock(LoginRateLimiterInterface::class);
|
||||
$this->requestStack = new RequestStack();
|
||||
|
||||
$this->dispatchedEvents = [];
|
||||
$eventBus = new class($this->dispatchedEvents) implements MessageBusInterface {
|
||||
/** @param DomainEvent[] $events */
|
||||
public function __construct(private array &$events)
|
||||
{
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function dispatch(object $message, array $stamps = []): Envelope
|
||||
{
|
||||
$this->events[] = $message;
|
||||
|
||||
return new Envelope($message);
|
||||
}
|
||||
};
|
||||
|
||||
$this->handler = new LoginSuccessHandler(
|
||||
$this->refreshTokenManager,
|
||||
$this->sessionRepository,
|
||||
$this->geoLocationService,
|
||||
$this->rateLimiter,
|
||||
$eventBus,
|
||||
$this->clock,
|
||||
$this->requestStack,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCreatesRefreshTokenOnSuccessfulLogin(): void
|
||||
{
|
||||
// GIVEN: A successful authentication event with SecurityUser
|
||||
$request = $this->createRequest();
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$securityUser = $this->createSecurityUser();
|
||||
$response = new Response();
|
||||
$event = new AuthenticationSuccessEvent(['token' => 'jwt'], $securityUser, $response);
|
||||
|
||||
$this->geoLocationService
|
||||
->method('locate')
|
||||
->willReturn(Location::unknown());
|
||||
|
||||
$this->sessionRepository
|
||||
->expects($this->once())
|
||||
->method('save')
|
||||
->with(
|
||||
$this->isInstanceOf(Session::class),
|
||||
$this->greaterThan(0),
|
||||
);
|
||||
|
||||
// WHEN: Handler processes the event
|
||||
$this->handler->onAuthenticationSuccess($event);
|
||||
|
||||
// THEN: Refresh token cookie is set
|
||||
$cookies = $response->headers->getCookies();
|
||||
$this->assertCount(1, $cookies);
|
||||
$this->assertSame('refresh_token', $cookies[0]->getName());
|
||||
$this->assertTrue($cookies[0]->isHttpOnly());
|
||||
$this->assertSame('/api', $cookies[0]->getPath());
|
||||
|
||||
// THEN: Refresh token is saved in repository
|
||||
$this->assertTrue(
|
||||
$this->refreshTokenRepository->hasActiveSessionsForUser(
|
||||
UserId::fromString(self::USER_ID),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itCreatesSessionWithDeviceInfoAndLocation(): void
|
||||
{
|
||||
// GIVEN: A successful authentication with request context
|
||||
$request = $this->createRequest();
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$securityUser = $this->createSecurityUser();
|
||||
$response = new Response();
|
||||
$event = new AuthenticationSuccessEvent(['token' => 'jwt'], $securityUser, $response);
|
||||
|
||||
$expectedLocation = Location::fromIp(self::IP_ADDRESS, 'France', 'Paris');
|
||||
|
||||
$this->geoLocationService
|
||||
->expects($this->once())
|
||||
->method('locate')
|
||||
->with(self::IP_ADDRESS)
|
||||
->willReturn($expectedLocation);
|
||||
|
||||
$savedSession = null;
|
||||
$this->sessionRepository
|
||||
->expects($this->once())
|
||||
->method('save')
|
||||
->willReturnCallback(static function (Session $session, int $ttl) use (&$savedSession): void {
|
||||
$savedSession = $session;
|
||||
});
|
||||
|
||||
// WHEN: Handler processes the event
|
||||
$this->handler->onAuthenticationSuccess($event);
|
||||
|
||||
// THEN: Session is created with correct data
|
||||
$this->assertNotNull($savedSession);
|
||||
$this->assertSame(self::USER_ID, (string) $savedSession->userId);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itResetsRateLimiterAfterSuccessfulLogin(): void
|
||||
{
|
||||
// GIVEN: A successful authentication
|
||||
$request = $this->createRequest();
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$securityUser = $this->createSecurityUser();
|
||||
$response = new Response();
|
||||
$event = new AuthenticationSuccessEvent(['token' => 'jwt'], $securityUser, $response);
|
||||
|
||||
$this->geoLocationService
|
||||
->method('locate')
|
||||
->willReturn(Location::unknown());
|
||||
|
||||
// THEN: Rate limiter should be reset for the user's email
|
||||
$this->rateLimiter
|
||||
->expects($this->once())
|
||||
->method('reset')
|
||||
->with(self::EMAIL);
|
||||
|
||||
// WHEN: Handler processes the event
|
||||
$this->handler->onAuthenticationSuccess($event);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itDispatchesConnexionReussieEvent(): void
|
||||
{
|
||||
// GIVEN: A successful authentication
|
||||
$request = $this->createRequest();
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$securityUser = $this->createSecurityUser();
|
||||
$response = new Response();
|
||||
$event = new AuthenticationSuccessEvent(['token' => 'jwt'], $securityUser, $response);
|
||||
|
||||
$this->geoLocationService
|
||||
->method('locate')
|
||||
->willReturn(Location::unknown());
|
||||
|
||||
// WHEN: Handler processes the event
|
||||
$this->handler->onAuthenticationSuccess($event);
|
||||
|
||||
// THEN: ConnexionReussie event is dispatched
|
||||
$loginEvents = array_filter(
|
||||
$this->dispatchedEvents,
|
||||
static fn ($e) => $e instanceof ConnexionReussie,
|
||||
);
|
||||
$this->assertCount(1, $loginEvents);
|
||||
|
||||
$loginEvent = reset($loginEvents);
|
||||
$this->assertSame(self::USER_ID, $loginEvent->userId);
|
||||
$this->assertSame(self::EMAIL, $loginEvent->email);
|
||||
$this->assertSame(self::IP_ADDRESS, $loginEvent->ipAddress);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itIgnoresNonSecurityUserAuthentication(): void
|
||||
{
|
||||
// GIVEN: An authentication event with a non-SecurityUser user
|
||||
$request = $this->createRequest();
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$genericUser = $this->createMock(UserInterface::class);
|
||||
$response = new Response();
|
||||
$event = new AuthenticationSuccessEvent(['token' => 'jwt'], $genericUser, $response);
|
||||
|
||||
// THEN: No operations should be performed
|
||||
$this->sessionRepository
|
||||
->expects($this->never())
|
||||
->method('save');
|
||||
|
||||
// WHEN: Handler processes the event
|
||||
$this->handler->onAuthenticationSuccess($event);
|
||||
|
||||
// THEN: No cookies added, no events dispatched
|
||||
$this->assertEmpty($response->headers->getCookies());
|
||||
$this->assertEmpty($this->dispatchedEvents);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itIgnoresEventWithoutRequest(): void
|
||||
{
|
||||
// GIVEN: No request in the stack
|
||||
$securityUser = $this->createSecurityUser();
|
||||
$response = new Response();
|
||||
$event = new AuthenticationSuccessEvent(['token' => 'jwt'], $securityUser, $response);
|
||||
|
||||
// THEN: No operations should be performed
|
||||
$this->sessionRepository
|
||||
->expects($this->never())
|
||||
->method('save');
|
||||
|
||||
// WHEN: Handler processes the event
|
||||
$this->handler->onAuthenticationSuccess($event);
|
||||
|
||||
// THEN: No cookies added
|
||||
$this->assertEmpty($response->headers->getCookies());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itSetsSecureCookieOnlyForHttps(): void
|
||||
{
|
||||
// GIVEN: An HTTP request (not HTTPS)
|
||||
$request = Request::create('http://localhost/login', 'POST', [], [], [], [
|
||||
'REMOTE_ADDR' => self::IP_ADDRESS,
|
||||
'HTTP_USER_AGENT' => self::USER_AGENT,
|
||||
]);
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$securityUser = $this->createSecurityUser();
|
||||
$response = new Response();
|
||||
$event = new AuthenticationSuccessEvent(['token' => 'jwt'], $securityUser, $response);
|
||||
|
||||
$this->geoLocationService
|
||||
->method('locate')
|
||||
->willReturn(Location::unknown());
|
||||
|
||||
// WHEN: Handler processes the event
|
||||
$this->handler->onAuthenticationSuccess($event);
|
||||
|
||||
// THEN: Cookie is NOT marked as secure (HTTP)
|
||||
$cookies = $response->headers->getCookies();
|
||||
$this->assertCount(1, $cookies);
|
||||
$this->assertFalse($cookies[0]->isSecure());
|
||||
}
|
||||
|
||||
private function createRequest(): Request
|
||||
{
|
||||
return Request::create('/login', 'POST', [], [], [], [
|
||||
'REMOTE_ADDR' => self::IP_ADDRESS,
|
||||
'HTTP_USER_AGENT' => self::USER_AGENT,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createSecurityUser(): SecurityUser
|
||||
{
|
||||
return new SecurityUser(
|
||||
userId: UserId::fromString(self::USER_ID),
|
||||
email: self::EMAIL,
|
||||
hashedPassword: '$argon2id$hashed',
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
roles: ['ROLE_PROF'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Unit\Shared\Domain;
|
||||
|
||||
use App\Shared\Domain\CorrelationId;
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
@@ -48,4 +49,19 @@ final class CorrelationIdTest extends TestCase
|
||||
|
||||
$this->assertNotSame($id1->value(), $id2->value());
|
||||
}
|
||||
|
||||
public function testFromStringRejectsInvalidUuid(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invalid correlation ID format');
|
||||
|
||||
CorrelationId::fromString('not-a-valid-uuid');
|
||||
}
|
||||
|
||||
public function testFromStringRejectsEmptyString(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
CorrelationId::fromString('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Audit;
|
||||
|
||||
use App\Shared\Infrastructure\Audit\AuditLogEntry;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
/**
|
||||
* @see Story 1.7 - T9: Requetes d'investigation
|
||||
*/
|
||||
final class AuditLogEntryTest extends TestCase
|
||||
{
|
||||
public function testFromDatabaseRow(): void
|
||||
{
|
||||
$id = Uuid::uuid4()->toString();
|
||||
$aggregateId = Uuid::uuid4()->toString();
|
||||
$row = [
|
||||
'id' => $id,
|
||||
'aggregate_type' => 'User',
|
||||
'aggregate_id' => $aggregateId,
|
||||
'event_type' => 'ConnexionReussie',
|
||||
'payload' => '{"email_hash":"abc123","result":"success"}',
|
||||
'metadata' => '{"tenant_id":"tenant-1","user_id":"user-1","correlation_id":"corr-1"}',
|
||||
'occurred_at' => '2026-02-03T10:30:00+00:00',
|
||||
'sequence_number' => '42',
|
||||
];
|
||||
|
||||
$entry = AuditLogEntry::fromDatabaseRow($row);
|
||||
|
||||
$this->assertEquals($id, $entry->id->toString());
|
||||
$this->assertSame('User', $entry->aggregateType);
|
||||
$this->assertEquals($aggregateId, $entry->aggregateId?->toString());
|
||||
$this->assertSame('ConnexionReussie', $entry->eventType);
|
||||
$this->assertSame(['email_hash' => 'abc123', 'result' => 'success'], $entry->payload);
|
||||
$this->assertSame('tenant-1', $entry->tenantId());
|
||||
$this->assertSame('user-1', $entry->userId());
|
||||
$this->assertSame('corr-1', $entry->correlationId());
|
||||
$this->assertSame(42, $entry->sequenceNumber);
|
||||
}
|
||||
|
||||
public function testFromDatabaseRowWithNullAggregateId(): void
|
||||
{
|
||||
$row = [
|
||||
'id' => Uuid::uuid4()->toString(),
|
||||
'aggregate_type' => 'Export',
|
||||
'aggregate_id' => null,
|
||||
'event_type' => 'ExportGenerated',
|
||||
'payload' => '{}',
|
||||
'metadata' => '{}',
|
||||
'occurred_at' => '2026-02-03T10:30:00+00:00',
|
||||
'sequence_number' => '1',
|
||||
];
|
||||
|
||||
$entry = AuditLogEntry::fromDatabaseRow($row);
|
||||
|
||||
$this->assertNull($entry->aggregateId);
|
||||
}
|
||||
|
||||
public function testMetadataAccessorsReturnNullWhenMissing(): void
|
||||
{
|
||||
$row = [
|
||||
'id' => Uuid::uuid4()->toString(),
|
||||
'aggregate_type' => 'User',
|
||||
'aggregate_id' => null,
|
||||
'event_type' => 'Test',
|
||||
'payload' => '{}',
|
||||
'metadata' => '{}',
|
||||
'occurred_at' => '2026-02-03T10:30:00+00:00',
|
||||
'sequence_number' => '1',
|
||||
];
|
||||
|
||||
$entry = AuditLogEntry::fromDatabaseRow($row);
|
||||
|
||||
$this->assertNull($entry->tenantId());
|
||||
$this->assertNull($entry->userId());
|
||||
$this->assertNull($entry->correlationId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Audit;
|
||||
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Audit\AuditLogEntry;
|
||||
use App\Shared\Infrastructure\Audit\AuditLogRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Query\QueryBuilder;
|
||||
use Doctrine\DBAL\Result;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
/**
|
||||
* @see Story 1.7 - T9: Requetes d'investigation
|
||||
*/
|
||||
final class AuditLogRepositoryTest extends TestCase
|
||||
{
|
||||
private Connection&MockObject $connection;
|
||||
private AuditLogRepository $repository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->connection = $this->createMock(Connection::class);
|
||||
$this->repository = new AuditLogRepository($this->connection);
|
||||
}
|
||||
|
||||
public function testFindByUserReturnsAuditLogEntries(): void
|
||||
{
|
||||
$userId = Uuid::uuid4();
|
||||
$tenantId = TenantId::generate();
|
||||
|
||||
$rows = [
|
||||
$this->createRowData(),
|
||||
$this->createRowData(),
|
||||
];
|
||||
|
||||
$queryBuilder = $this->createQueryBuilderMock($rows);
|
||||
$this->connection->method('createQueryBuilder')->willReturn($queryBuilder);
|
||||
|
||||
$result = $this->repository->findByUser($userId, $tenantId);
|
||||
|
||||
$this->assertCount(2, $result);
|
||||
$this->assertContainsOnlyInstancesOf(AuditLogEntry::class, $result);
|
||||
}
|
||||
|
||||
public function testFindByUserAppliesFilters(): void
|
||||
{
|
||||
$userId = Uuid::uuid4();
|
||||
$tenantId = TenantId::generate();
|
||||
$from = new DateTimeImmutable('2026-01-01');
|
||||
$to = new DateTimeImmutable('2026-02-01');
|
||||
|
||||
$queryBuilder = $this->createMock(QueryBuilder::class);
|
||||
$queryBuilder->method('select')->willReturnSelf();
|
||||
$queryBuilder->method('from')->willReturnSelf();
|
||||
$queryBuilder->method('where')->willReturnSelf();
|
||||
$queryBuilder->method('orderBy')->willReturnSelf();
|
||||
|
||||
// Verify andWhere is called for each filter
|
||||
$queryBuilder->expects($this->atLeast(4))
|
||||
->method('andWhere')
|
||||
->willReturnSelf();
|
||||
|
||||
// Verify setParameter is called with expected parameters
|
||||
$capturedParams = [];
|
||||
$queryBuilder->method('setParameter')
|
||||
->willReturnCallback(static function (string $key, mixed $value) use ($queryBuilder, &$capturedParams) {
|
||||
$capturedParams[$key] = $value;
|
||||
|
||||
return $queryBuilder;
|
||||
});
|
||||
|
||||
// Verify pagination
|
||||
$queryBuilder->expects($this->once())
|
||||
->method('setMaxResults')
|
||||
->with(50)
|
||||
->willReturnSelf();
|
||||
|
||||
$queryBuilder->expects($this->once())
|
||||
->method('setFirstResult')
|
||||
->with(10)
|
||||
->willReturnSelf();
|
||||
|
||||
$result = $this->createMock(Result::class);
|
||||
$result->method('fetchAllAssociative')->willReturn([]);
|
||||
$queryBuilder->method('executeQuery')->willReturn($result);
|
||||
|
||||
$this->connection->method('createQueryBuilder')->willReturn($queryBuilder);
|
||||
|
||||
$this->repository->findByUser(
|
||||
$userId,
|
||||
$tenantId,
|
||||
$from,
|
||||
$to,
|
||||
'ConnexionReussie',
|
||||
50,
|
||||
10,
|
||||
);
|
||||
|
||||
// Verify the parameters were captured
|
||||
$this->assertArrayHasKey('user_id', $capturedParams);
|
||||
$this->assertArrayHasKey('tenant_id', $capturedParams);
|
||||
$this->assertArrayHasKey('from', $capturedParams);
|
||||
$this->assertArrayHasKey('to', $capturedParams);
|
||||
$this->assertArrayHasKey('event_type', $capturedParams);
|
||||
$this->assertSame('ConnexionReussie', $capturedParams['event_type']);
|
||||
}
|
||||
|
||||
public function testFindByResourceReturnsResults(): void
|
||||
{
|
||||
$aggregateId = Uuid::uuid4();
|
||||
$tenantId = TenantId::generate();
|
||||
|
||||
$rows = [$this->createRowData()];
|
||||
|
||||
$queryBuilder = $this->createQueryBuilderMock($rows);
|
||||
$this->connection->method('createQueryBuilder')->willReturn($queryBuilder);
|
||||
|
||||
$result = $this->repository->findByResource('Note', $aggregateId, $tenantId);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
}
|
||||
|
||||
public function testFindByCorrelationIdReturnsResults(): void
|
||||
{
|
||||
$correlationId = Uuid::uuid4()->toString();
|
||||
$tenantId = TenantId::generate();
|
||||
|
||||
$rows = [$this->createRowData()];
|
||||
|
||||
$queryBuilder = $this->createQueryBuilderMock($rows);
|
||||
$this->connection->method('createQueryBuilder')->willReturn($queryBuilder);
|
||||
|
||||
$result = $this->repository->findByCorrelationId($correlationId, $tenantId);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
}
|
||||
|
||||
public function testSearchWithAllFilters(): void
|
||||
{
|
||||
$tenantId = TenantId::generate();
|
||||
$from = new DateTimeImmutable('2026-01-01');
|
||||
$to = new DateTimeImmutable('2026-02-01');
|
||||
|
||||
$rows = [$this->createRowData()];
|
||||
|
||||
$queryBuilder = $this->createQueryBuilderMock($rows);
|
||||
$this->connection->method('createQueryBuilder')->willReturn($queryBuilder);
|
||||
|
||||
$result = $this->repository->search(
|
||||
$tenantId,
|
||||
$from,
|
||||
$to,
|
||||
'ConnexionReussie',
|
||||
'User',
|
||||
100,
|
||||
0,
|
||||
);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
}
|
||||
|
||||
public function testEmptyResultReturnsEmptyArray(): void
|
||||
{
|
||||
$userId = Uuid::uuid4();
|
||||
$tenantId = TenantId::generate();
|
||||
|
||||
$queryBuilder = $this->createQueryBuilderMock([]);
|
||||
$this->connection->method('createQueryBuilder')->willReturn($queryBuilder);
|
||||
|
||||
$result = $this->repository->findByUser($userId, $tenantId);
|
||||
|
||||
$this->assertSame([], $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function createRowData(): array
|
||||
{
|
||||
return [
|
||||
'id' => Uuid::uuid4()->toString(),
|
||||
'aggregate_type' => 'User',
|
||||
'aggregate_id' => Uuid::uuid4()->toString(),
|
||||
'event_type' => 'ConnexionReussie',
|
||||
'payload' => '{"email_hash":"abc123"}',
|
||||
'metadata' => '{"tenant_id":"tenant-1","user_id":"user-1"}',
|
||||
'occurred_at' => '2026-02-03T10:30:00+00:00',
|
||||
'sequence_number' => '1',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function createQueryBuilderMock(array $rows): QueryBuilder&MockObject
|
||||
{
|
||||
$queryBuilder = $this->createMock(QueryBuilder::class);
|
||||
$queryBuilder->method('select')->willReturnSelf();
|
||||
$queryBuilder->method('from')->willReturnSelf();
|
||||
$queryBuilder->method('where')->willReturnSelf();
|
||||
$queryBuilder->method('andWhere')->willReturnSelf();
|
||||
$queryBuilder->method('setParameter')->willReturnSelf();
|
||||
$queryBuilder->method('orderBy')->willReturnSelf();
|
||||
$queryBuilder->method('setMaxResults')->willReturnSelf();
|
||||
$queryBuilder->method('setFirstResult')->willReturnSelf();
|
||||
|
||||
$result = $this->createMock(Result::class);
|
||||
$result->method('fetchAllAssociative')->willReturn($rows);
|
||||
$queryBuilder->method('executeQuery')->willReturn($result);
|
||||
|
||||
return $queryBuilder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Audit;
|
||||
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\CorrelationId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Audit\AuditLogger;
|
||||
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
|
||||
use App\Shared\Infrastructure\Tenant\TenantConfig;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
use App\Shared\Infrastructure\Tenant\TenantId as InfrastructureTenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
|
||||
/**
|
||||
* @see Story 1.7 - T2: AuditLogger Service
|
||||
*/
|
||||
final class AuditLoggerTest extends TestCase
|
||||
{
|
||||
private Connection&MockObject $connection;
|
||||
private TenantContext $tenantContext;
|
||||
private TokenStorageInterface&MockObject $tokenStorage;
|
||||
private RequestStack $requestStack;
|
||||
private Clock&MockObject $clock;
|
||||
private AuditLogger $auditLogger;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->connection = $this->createMock(Connection::class);
|
||||
$this->tenantContext = new TenantContext();
|
||||
$this->tokenStorage = $this->createMock(TokenStorageInterface::class);
|
||||
$this->requestStack = new RequestStack();
|
||||
$this->clock = $this->createMock(Clock::class);
|
||||
|
||||
$this->auditLogger = new AuditLogger(
|
||||
$this->connection,
|
||||
$this->tenantContext,
|
||||
$this->tokenStorage,
|
||||
$this->requestStack,
|
||||
$this->clock,
|
||||
'test-secret',
|
||||
);
|
||||
|
||||
CorrelationIdHolder::clear();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
CorrelationIdHolder::clear();
|
||||
$this->tenantContext->clear();
|
||||
}
|
||||
|
||||
public function testLogAuthenticationInsertsIntoDatabase(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
$this->tokenStorage->method('getToken')->willReturn(null);
|
||||
|
||||
$userId = Uuid::uuid4();
|
||||
|
||||
$this->connection->expects($this->once())
|
||||
->method('insert')
|
||||
->with(
|
||||
'audit_log',
|
||||
$this->callback(static function (array $data) use ($userId): bool {
|
||||
return $data['aggregate_type'] === 'User'
|
||||
&& $data['aggregate_id'] === $userId->toString()
|
||||
&& $data['event_type'] === 'TestEvent'
|
||||
&& $data['payload']['test_key'] === 'test_value';
|
||||
}),
|
||||
$this->anything(),
|
||||
);
|
||||
|
||||
$this->auditLogger->logAuthentication('TestEvent', $userId, ['test_key' => 'test_value']);
|
||||
}
|
||||
|
||||
public function testLogAuthenticationWithNullUserIdSetsNullAggregateId(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
$this->tokenStorage->method('getToken')->willReturn(null);
|
||||
|
||||
$this->connection->expects($this->once())
|
||||
->method('insert')
|
||||
->with(
|
||||
'audit_log',
|
||||
$this->callback(static fn (array $data): bool => $data['aggregate_id'] === null),
|
||||
$this->anything(),
|
||||
);
|
||||
|
||||
$this->auditLogger->logAuthentication('FailedLogin', null, []);
|
||||
}
|
||||
|
||||
public function testLogDataChangeIncludesOldAndNewValues(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
$this->tokenStorage->method('getToken')->willReturn(null);
|
||||
|
||||
$aggregateId = Uuid::uuid4();
|
||||
|
||||
$this->connection->expects($this->once())
|
||||
->method('insert')
|
||||
->with(
|
||||
'audit_log',
|
||||
$this->callback(static function (array $data) use ($aggregateId): bool {
|
||||
$payload = $data['payload'];
|
||||
|
||||
return $data['aggregate_type'] === 'Note'
|
||||
&& $data['aggregate_id'] === $aggregateId->toString()
|
||||
&& $data['event_type'] === 'NoteModified'
|
||||
&& $payload['old_values']['value'] === 12.5
|
||||
&& $payload['new_values']['value'] === 14.0
|
||||
&& $payload['reason'] === 'Correction';
|
||||
}),
|
||||
$this->anything(),
|
||||
);
|
||||
|
||||
$this->auditLogger->logDataChange(
|
||||
'Note',
|
||||
$aggregateId,
|
||||
'NoteModified',
|
||||
['value' => 12.5],
|
||||
['value' => 14.0],
|
||||
'Correction',
|
||||
);
|
||||
}
|
||||
|
||||
public function testLogExportIncludesExportDetails(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
$this->tokenStorage->method('getToken')->willReturn(null);
|
||||
|
||||
$this->connection->expects($this->once())
|
||||
->method('insert')
|
||||
->with(
|
||||
'audit_log',
|
||||
$this->callback(static function (array $data): bool {
|
||||
$payload = $data['payload'];
|
||||
|
||||
return $data['aggregate_type'] === 'Export'
|
||||
&& $data['event_type'] === 'ExportGenerated'
|
||||
&& $payload['export_type'] === 'CSV'
|
||||
&& $payload['record_count'] === 150
|
||||
&& $payload['target'] === 'students_list';
|
||||
}),
|
||||
$this->anything(),
|
||||
);
|
||||
|
||||
$this->auditLogger->logExport('CSV', 150, 'students_list');
|
||||
}
|
||||
|
||||
public function testLogAccessIncludesResourceAndContext(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
$this->tokenStorage->method('getToken')->willReturn(null);
|
||||
|
||||
$resourceId = Uuid::uuid4();
|
||||
|
||||
$this->connection->expects($this->once())
|
||||
->method('insert')
|
||||
->with(
|
||||
'audit_log',
|
||||
$this->callback(static function (array $data) use ($resourceId): bool {
|
||||
$payload = $data['payload'];
|
||||
|
||||
return $data['aggregate_type'] === 'Student'
|
||||
&& $data['aggregate_id'] === $resourceId->toString()
|
||||
&& $data['event_type'] === 'ResourceAccessed'
|
||||
&& $payload['screen'] === 'profile'
|
||||
&& $payload['action'] === 'view';
|
||||
}),
|
||||
$this->anything(),
|
||||
);
|
||||
|
||||
$this->auditLogger->logAccess('Student', $resourceId, ['screen' => 'profile', 'action' => 'view']);
|
||||
}
|
||||
|
||||
public function testMetadataIncludesTenantIdWhenAvailable(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
$this->tokenStorage->method('getToken')->willReturn(null);
|
||||
|
||||
$tenantId = TenantId::generate();
|
||||
$this->setCurrentTenant($tenantId);
|
||||
|
||||
$this->connection->expects($this->once())
|
||||
->method('insert')
|
||||
->with(
|
||||
'audit_log',
|
||||
$this->callback(static function (array $data) use ($tenantId): bool {
|
||||
$metadata = $data['metadata'];
|
||||
|
||||
return $metadata['tenant_id'] === (string) $tenantId;
|
||||
}),
|
||||
$this->anything(),
|
||||
);
|
||||
|
||||
$this->auditLogger->logAuthentication('Test', null, []);
|
||||
}
|
||||
|
||||
public function testMetadataIncludesCorrelationIdWhenSet(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
$this->tokenStorage->method('getToken')->willReturn(null);
|
||||
|
||||
$correlationId = CorrelationId::generate();
|
||||
CorrelationIdHolder::set($correlationId);
|
||||
|
||||
$this->connection->expects($this->once())
|
||||
->method('insert')
|
||||
->with(
|
||||
'audit_log',
|
||||
$this->callback(static function (array $data) use ($correlationId): bool {
|
||||
$metadata = $data['metadata'];
|
||||
|
||||
return $metadata['correlation_id'] === $correlationId->value();
|
||||
}),
|
||||
$this->anything(),
|
||||
);
|
||||
|
||||
$this->auditLogger->logAuthentication('Test', null, []);
|
||||
}
|
||||
|
||||
public function testMetadataIncludesHashedIpFromRequest(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
$this->tokenStorage->method('getToken')->willReturn(null);
|
||||
|
||||
$request = Request::create('/test');
|
||||
$request->server->set('REMOTE_ADDR', '192.168.1.100');
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$expectedIpHash = hash('sha256', '192.168.1.100test-secret');
|
||||
|
||||
$this->connection->expects($this->once())
|
||||
->method('insert')
|
||||
->with(
|
||||
'audit_log',
|
||||
$this->callback(static function (array $data) use ($expectedIpHash): bool {
|
||||
$metadata = $data['metadata'];
|
||||
|
||||
return $metadata['ip_hash'] === $expectedIpHash;
|
||||
}),
|
||||
$this->anything(),
|
||||
);
|
||||
|
||||
$this->auditLogger->logAuthentication('Test', null, []);
|
||||
}
|
||||
|
||||
public function testMetadataIncludesUserAgentHash(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
$this->tokenStorage->method('getToken')->willReturn(null);
|
||||
|
||||
$request = Request::create('/test');
|
||||
$request->headers->set('User-Agent', 'Mozilla/5.0 TestBrowser');
|
||||
$this->requestStack->push($request);
|
||||
|
||||
$expectedUaHash = hash('sha256', 'Mozilla/5.0 TestBrowser');
|
||||
|
||||
$this->connection->expects($this->once())
|
||||
->method('insert')
|
||||
->with(
|
||||
'audit_log',
|
||||
$this->callback(static function (array $data) use ($expectedUaHash): bool {
|
||||
$metadata = $data['metadata'];
|
||||
|
||||
return $metadata['user_agent_hash'] === $expectedUaHash;
|
||||
}),
|
||||
$this->anything(),
|
||||
);
|
||||
|
||||
$this->auditLogger->logAuthentication('Test', null, []);
|
||||
}
|
||||
|
||||
public function testLogAuthenticationWithTenantIdOverride(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
$this->tokenStorage->method('getToken')->willReturn(null);
|
||||
|
||||
$overrideTenantId = 'override-tenant-uuid-1234';
|
||||
|
||||
$this->connection->expects($this->once())
|
||||
->method('insert')
|
||||
->with(
|
||||
'audit_log',
|
||||
$this->callback(static function (array $data) use ($overrideTenantId): bool {
|
||||
$metadata = $data['metadata'];
|
||||
|
||||
return $metadata['tenant_id'] === $overrideTenantId;
|
||||
}),
|
||||
$this->anything(),
|
||||
);
|
||||
|
||||
// No TenantContext set, but override should be used
|
||||
$this->auditLogger->logAuthentication('Test', null, [], $overrideTenantId);
|
||||
}
|
||||
|
||||
public function testTenantIdOverrideTakesPrecedenceOverContext(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:30:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
$this->tokenStorage->method('getToken')->willReturn(null);
|
||||
|
||||
// Set a tenant in context
|
||||
$contextTenantId = TenantId::generate();
|
||||
$this->setCurrentTenant($contextTenantId);
|
||||
|
||||
// But use a different override
|
||||
$overrideTenantId = 'override-tenant-uuid-5678';
|
||||
|
||||
$this->connection->expects($this->once())
|
||||
->method('insert')
|
||||
->with(
|
||||
'audit_log',
|
||||
$this->callback(static function (array $data) use ($overrideTenantId): bool {
|
||||
$metadata = $data['metadata'];
|
||||
|
||||
// Override should take precedence over context
|
||||
return $metadata['tenant_id'] === $overrideTenantId;
|
||||
}),
|
||||
$this->anything(),
|
||||
);
|
||||
|
||||
$this->auditLogger->logAuthentication('Test', null, [], $overrideTenantId);
|
||||
}
|
||||
|
||||
private function setCurrentTenant(TenantId $tenantId): void
|
||||
{
|
||||
$config = new TenantConfig(
|
||||
tenantId: InfrastructureTenantId::fromString((string) $tenantId),
|
||||
subdomain: 'test-tenant',
|
||||
databaseUrl: 'postgresql://user:pass@localhost:5432/classeo_test',
|
||||
);
|
||||
$this->tenantContext->setCurrentTenant($config);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Audit;
|
||||
|
||||
use App\Shared\Domain\CorrelationId;
|
||||
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @see Story 1.7 - T3: Correlation ID
|
||||
*/
|
||||
final class CorrelationIdHolderTest extends TestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
CorrelationIdHolder::clear();
|
||||
}
|
||||
|
||||
public function testInitiallyReturnsNull(): void
|
||||
{
|
||||
CorrelationIdHolder::clear();
|
||||
|
||||
$this->assertNull(CorrelationIdHolder::get());
|
||||
}
|
||||
|
||||
public function testSetAndGetCorrelationId(): void
|
||||
{
|
||||
$correlationId = CorrelationId::generate();
|
||||
|
||||
CorrelationIdHolder::set($correlationId);
|
||||
|
||||
$this->assertSame($correlationId, CorrelationIdHolder::get());
|
||||
}
|
||||
|
||||
public function testClearRemovesCorrelationId(): void
|
||||
{
|
||||
CorrelationIdHolder::set(CorrelationId::generate());
|
||||
|
||||
CorrelationIdHolder::clear();
|
||||
|
||||
$this->assertNull(CorrelationIdHolder::get());
|
||||
}
|
||||
|
||||
public function testSetOverwritesPreviousValue(): void
|
||||
{
|
||||
$first = CorrelationId::generate();
|
||||
$second = CorrelationId::generate();
|
||||
|
||||
CorrelationIdHolder::set($first);
|
||||
CorrelationIdHolder::set($second);
|
||||
|
||||
$this->assertSame($second, CorrelationIdHolder::get());
|
||||
}
|
||||
|
||||
public function testGetOrGenerateReturnsExistingId(): void
|
||||
{
|
||||
$existing = CorrelationId::generate();
|
||||
CorrelationIdHolder::set($existing);
|
||||
|
||||
$result = CorrelationIdHolder::getOrGenerate();
|
||||
|
||||
$this->assertSame($existing, $result);
|
||||
}
|
||||
|
||||
public function testGetOrGenerateCreatesNewIdWhenNoneSet(): void
|
||||
{
|
||||
CorrelationIdHolder::clear();
|
||||
|
||||
$result = CorrelationIdHolder::getOrGenerate();
|
||||
|
||||
$this->assertNotNull($result);
|
||||
$this->assertInstanceOf(CorrelationId::class, $result);
|
||||
}
|
||||
|
||||
public function testGetOrGenerateStoresGeneratedId(): void
|
||||
{
|
||||
CorrelationIdHolder::clear();
|
||||
|
||||
$first = CorrelationIdHolder::getOrGenerate();
|
||||
$second = CorrelationIdHolder::getOrGenerate();
|
||||
|
||||
// Should return the same generated ID, not create a new one each time
|
||||
$this->assertSame($first, $second);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Audit;
|
||||
|
||||
use App\Shared\Domain\CorrelationId;
|
||||
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
|
||||
use App\Shared\Infrastructure\Middleware\CorrelationIdMiddleware;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
use Symfony\Component\HttpKernel\Event\TerminateEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
|
||||
/**
|
||||
* @see Story 1.7 - T3: Correlation ID
|
||||
*/
|
||||
final class CorrelationIdMiddlewareTest extends TestCase
|
||||
{
|
||||
private CorrelationIdMiddleware $middleware;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->middleware = new CorrelationIdMiddleware();
|
||||
CorrelationIdHolder::clear();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
CorrelationIdHolder::clear();
|
||||
}
|
||||
|
||||
public function testGeneratesCorrelationIdWhenHeaderMissing(): void
|
||||
{
|
||||
$request = Request::create('/test');
|
||||
$event = $this->createRequestEvent($request, true);
|
||||
|
||||
$this->middleware->onKernelRequest($event);
|
||||
|
||||
$correlationId = CorrelationIdHolder::get();
|
||||
$this->assertNotNull($correlationId);
|
||||
$this->assertTrue(Uuid::isValid($correlationId->value()));
|
||||
}
|
||||
|
||||
public function testUsesExistingCorrelationIdFromHeader(): void
|
||||
{
|
||||
$existingId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
$request = Request::create('/test');
|
||||
$request->headers->set('X-Correlation-Id', $existingId);
|
||||
$event = $this->createRequestEvent($request, true);
|
||||
|
||||
$this->middleware->onKernelRequest($event);
|
||||
|
||||
$correlationId = CorrelationIdHolder::get();
|
||||
$this->assertNotNull($correlationId);
|
||||
$this->assertSame($existingId, $correlationId->value());
|
||||
}
|
||||
|
||||
public function testGeneratesNewIdWhenHeaderContainsInvalidUuid(): void
|
||||
{
|
||||
$request = Request::create('/test');
|
||||
$request->headers->set('X-Correlation-Id', 'not-a-valid-uuid');
|
||||
$event = $this->createRequestEvent($request, true);
|
||||
|
||||
$this->middleware->onKernelRequest($event);
|
||||
|
||||
$correlationId = CorrelationIdHolder::get();
|
||||
$this->assertNotNull($correlationId);
|
||||
// Should have generated a new valid UUID, not kept the invalid one
|
||||
$this->assertTrue(Uuid::isValid($correlationId->value()));
|
||||
$this->assertNotSame('not-a-valid-uuid', $correlationId->value());
|
||||
}
|
||||
|
||||
public function testStoresCorrelationIdInRequestAttributes(): void
|
||||
{
|
||||
$request = Request::create('/test');
|
||||
$event = $this->createRequestEvent($request, true);
|
||||
|
||||
$this->middleware->onKernelRequest($event);
|
||||
|
||||
$this->assertInstanceOf(CorrelationId::class, $request->attributes->get('correlation_id'));
|
||||
}
|
||||
|
||||
public function testIgnoresSubRequests(): void
|
||||
{
|
||||
$request = Request::create('/test');
|
||||
$event = $this->createRequestEvent($request, false);
|
||||
|
||||
$this->middleware->onKernelRequest($event);
|
||||
|
||||
$this->assertNull(CorrelationIdHolder::get());
|
||||
}
|
||||
|
||||
public function testAddsCorrelationIdHeaderToResponse(): void
|
||||
{
|
||||
$correlationId = CorrelationId::generate();
|
||||
CorrelationIdHolder::set($correlationId);
|
||||
|
||||
$request = Request::create('/test');
|
||||
$response = new Response();
|
||||
$event = $this->createResponseEvent($request, $response, true);
|
||||
|
||||
$this->middleware->onKernelResponse($event);
|
||||
|
||||
$this->assertSame(
|
||||
$correlationId->value(),
|
||||
$response->headers->get('X-Correlation-Id'),
|
||||
);
|
||||
}
|
||||
|
||||
public function testDoesNotAddHeaderToSubRequestResponse(): void
|
||||
{
|
||||
$correlationId = CorrelationId::generate();
|
||||
CorrelationIdHolder::set($correlationId);
|
||||
|
||||
$request = Request::create('/test');
|
||||
$response = new Response();
|
||||
$event = $this->createResponseEvent($request, $response, false);
|
||||
|
||||
$this->middleware->onKernelResponse($event);
|
||||
|
||||
$this->assertNull($response->headers->get('X-Correlation-Id'));
|
||||
}
|
||||
|
||||
public function testClearsCorrelationIdOnTerminate(): void
|
||||
{
|
||||
CorrelationIdHolder::set(CorrelationId::generate());
|
||||
|
||||
$request = Request::create('/test');
|
||||
$response = new Response();
|
||||
$event = $this->createTerminateEvent($request, $response);
|
||||
|
||||
$this->middleware->onKernelTerminate($event);
|
||||
|
||||
$this->assertNull(CorrelationIdHolder::get());
|
||||
}
|
||||
|
||||
public function testDefensiveClearOnNewRequest(): void
|
||||
{
|
||||
// Simulate a stale correlation ID from a previous request that didn't clean up
|
||||
$staleId = CorrelationId::generate();
|
||||
CorrelationIdHolder::set($staleId);
|
||||
|
||||
$request = Request::create('/test');
|
||||
$event = $this->createRequestEvent($request, true);
|
||||
|
||||
$this->middleware->onKernelRequest($event);
|
||||
|
||||
// Should have a NEW correlation ID, not the stale one
|
||||
$newId = CorrelationIdHolder::get();
|
||||
$this->assertNotNull($newId);
|
||||
$this->assertNotSame($staleId->value(), $newId->value());
|
||||
}
|
||||
|
||||
private function createRequestEvent(Request $request, bool $isMainRequest): RequestEvent
|
||||
{
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
|
||||
return new RequestEvent(
|
||||
$kernel,
|
||||
$request,
|
||||
$isMainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
private function createResponseEvent(Request $request, Response $response, bool $isMainRequest): ResponseEvent
|
||||
{
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
|
||||
return new ResponseEvent(
|
||||
$kernel,
|
||||
$request,
|
||||
$isMainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST,
|
||||
$response,
|
||||
);
|
||||
}
|
||||
|
||||
private function createTerminateEvent(Request $request, Response $response): TerminateEvent
|
||||
{
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
|
||||
return new TerminateEvent($kernel, $request, $response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Audit\Handler;
|
||||
|
||||
use App\Administration\Domain\Event\CompteBloqueTemporairement;
|
||||
use App\Administration\Domain\Event\ConnexionEchouee;
|
||||
use App\Administration\Domain\Event\ConnexionReussie;
|
||||
use App\Administration\Domain\Event\MotDePasseChange;
|
||||
use App\Shared\Application\Port\AuditLogger;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Audit\Handler\AuditAuthenticationHandler;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
|
||||
/**
|
||||
* @see Story 1.7 - T4: Listeners Authentification
|
||||
*/
|
||||
final class AuditAuthenticationHandlerTest extends TestCase
|
||||
{
|
||||
private AuditLogger&MockObject $auditLogger;
|
||||
private AuditAuthenticationHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->auditLogger = $this->createMock(AuditLogger::class);
|
||||
$this->handler = new AuditAuthenticationHandler(
|
||||
$this->auditLogger,
|
||||
'test-secret',
|
||||
);
|
||||
}
|
||||
|
||||
public function testHandleConnexionReussieLogsSuccessfulLogin(): void
|
||||
{
|
||||
$userId = Uuid::uuid4()->toString();
|
||||
$event = new ConnexionReussie(
|
||||
userId: $userId,
|
||||
email: 'user@example.com',
|
||||
tenantId: TenantId::generate(),
|
||||
ipAddress: '192.168.1.1',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
occurredOn: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
$this->auditLogger->expects($this->once())
|
||||
->method('logAuthentication')
|
||||
->with(
|
||||
$this->equalTo('ConnexionReussie'),
|
||||
$this->callback(static fn ($uuid) => $uuid->toString() === $userId),
|
||||
$this->callback(static fn ($payload) => $payload['result'] === 'success'
|
||||
&& $payload['method'] === 'password'
|
||||
&& isset($payload['email_hash'])
|
||||
),
|
||||
);
|
||||
|
||||
$this->handler->handleConnexionReussie($event);
|
||||
}
|
||||
|
||||
public function testHandleConnexionEchoueeLogsFailedLogin(): void
|
||||
{
|
||||
$tenantId = TenantId::generate();
|
||||
$event = new ConnexionEchouee(
|
||||
email: 'user@example.com',
|
||||
tenantId: $tenantId,
|
||||
ipAddress: '192.168.1.1',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
reason: 'invalid_credentials',
|
||||
occurredOn: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
$this->auditLogger->expects($this->once())
|
||||
->method('logAuthentication')
|
||||
->with(
|
||||
$this->equalTo('ConnexionEchouee'),
|
||||
$this->isNull(),
|
||||
$this->callback(static fn ($payload) => $payload['result'] === 'failure'
|
||||
&& $payload['reason'] === 'invalid_credentials'
|
||||
&& isset($payload['email_hash'])
|
||||
),
|
||||
$this->equalTo((string) $tenantId),
|
||||
);
|
||||
|
||||
$this->handler->handleConnexionEchouee($event);
|
||||
}
|
||||
|
||||
public function testHandleCompteBloqueTemporairementLogsLockout(): void
|
||||
{
|
||||
$tenantId = TenantId::generate();
|
||||
$event = new CompteBloqueTemporairement(
|
||||
email: 'user@example.com',
|
||||
tenantId: $tenantId,
|
||||
ipAddress: '192.168.1.1',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
blockedForSeconds: 300,
|
||||
failedAttempts: 5,
|
||||
occurredOn: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
$this->auditLogger->expects($this->once())
|
||||
->method('logAuthentication')
|
||||
->with(
|
||||
$this->equalTo('CompteBloqueTemporairement'),
|
||||
$this->isNull(),
|
||||
$this->callback(static fn ($payload) => $payload['blocked_for_seconds'] === 300
|
||||
&& $payload['failed_attempts'] === 5
|
||||
&& isset($payload['email_hash'])
|
||||
),
|
||||
$this->equalTo((string) $tenantId),
|
||||
);
|
||||
|
||||
$this->handler->handleCompteBloqueTemporairement($event);
|
||||
}
|
||||
|
||||
public function testHandleMotDePasseChangeLogsPasswordChange(): void
|
||||
{
|
||||
$userId = Uuid::uuid4()->toString();
|
||||
$event = new MotDePasseChange(
|
||||
userId: $userId,
|
||||
email: 'user@example.com',
|
||||
tenantId: TenantId::generate(),
|
||||
occurredOn: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
$this->auditLogger->expects($this->once())
|
||||
->method('logAuthentication')
|
||||
->with(
|
||||
$this->equalTo('MotDePasseChange'),
|
||||
$this->callback(static fn ($uuid) => $uuid->toString() === $userId),
|
||||
$this->callback(static fn ($payload) => isset($payload['email_hash'])),
|
||||
);
|
||||
|
||||
$this->handler->handleMotDePasseChange($event);
|
||||
}
|
||||
|
||||
public function testEmailIsHashedConsistently(): void
|
||||
{
|
||||
$email = 'user@example.com';
|
||||
$expectedHash = hash('sha256', strtolower($email) . 'test-secret');
|
||||
|
||||
$capturedPayload = null;
|
||||
$this->auditLogger->expects($this->once())
|
||||
->method('logAuthentication')
|
||||
->willReturnCallback(static function ($eventType, $userId, $payload) use (&$capturedPayload) {
|
||||
$capturedPayload = $payload;
|
||||
});
|
||||
|
||||
$event = new ConnexionReussie(
|
||||
userId: Uuid::uuid4()->toString(),
|
||||
email: $email,
|
||||
tenantId: TenantId::generate(),
|
||||
ipAddress: '192.168.1.1',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
occurredOn: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
$this->handler->handleConnexionReussie($event);
|
||||
|
||||
$this->assertSame($expectedHash, $capturedPayload['email_hash']);
|
||||
}
|
||||
|
||||
public function testEmailHashIsCaseInsensitive(): void
|
||||
{
|
||||
$lowerEmail = 'user@example.com';
|
||||
$upperEmail = 'USER@EXAMPLE.COM';
|
||||
$expectedHash = hash('sha256', strtolower($lowerEmail) . 'test-secret');
|
||||
|
||||
$payloads = [];
|
||||
$this->auditLogger->expects($this->exactly(2))
|
||||
->method('logAuthentication')
|
||||
->willReturnCallback(static function ($eventType, $userId, $payload) use (&$payloads) {
|
||||
$payloads[] = $payload;
|
||||
});
|
||||
|
||||
$event1 = new ConnexionReussie(
|
||||
userId: Uuid::uuid4()->toString(),
|
||||
email: $lowerEmail,
|
||||
tenantId: TenantId::generate(),
|
||||
ipAddress: '192.168.1.1',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
occurredOn: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
$event2 = new ConnexionReussie(
|
||||
userId: Uuid::uuid4()->toString(),
|
||||
email: $upperEmail,
|
||||
tenantId: TenantId::generate(),
|
||||
ipAddress: '192.168.1.1',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
occurredOn: new DateTimeImmutable(),
|
||||
);
|
||||
|
||||
$this->handler->handleConnexionReussie($event1);
|
||||
$this->handler->handleConnexionReussie($event2);
|
||||
|
||||
$this->assertSame($payloads[0]['email_hash'], $payloads[1]['email_hash']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Console;
|
||||
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Infrastructure\Console\ArchiveAuditLogsCommand;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
/**
|
||||
* @see Story 1.7 - T8: Archivage
|
||||
*/
|
||||
final class ArchiveAuditLogsCommandTest extends TestCase
|
||||
{
|
||||
private Connection&MockObject $connection;
|
||||
private Clock&MockObject $clock;
|
||||
private ArchiveAuditLogsCommand $command;
|
||||
private CommandTester $commandTester;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->connection = $this->createMock(Connection::class);
|
||||
$this->clock = $this->createMock(Clock::class);
|
||||
|
||||
$this->command = new ArchiveAuditLogsCommand(
|
||||
$this->connection,
|
||||
$this->clock,
|
||||
);
|
||||
|
||||
$this->commandTester = new CommandTester($this->command);
|
||||
}
|
||||
|
||||
public function testCommandNameIsCorrect(): void
|
||||
{
|
||||
$this->assertSame('app:audit:archive', $this->command->getName());
|
||||
}
|
||||
|
||||
public function testCommandDescription(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
'Archive audit log entries older than 5 years',
|
||||
$this->command->getDescription(),
|
||||
);
|
||||
}
|
||||
|
||||
public function testNoEntriesToArchiveReturnsSuccess(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:00:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
|
||||
$this->connection->method('fetchOne')
|
||||
->willReturnOnConsecutiveCalls(0); // COUNT returns 0
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('No entries to archive', $output);
|
||||
$this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testDryRunDoesNotCallArchiveFunction(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:00:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
|
||||
$this->connection->expects($this->once())
|
||||
->method('fetchOne')
|
||||
->willReturn(100); // 100 entries to archive
|
||||
|
||||
// archive_audit_entries should NOT be called in dry-run mode
|
||||
$this->connection->expects($this->never())
|
||||
->method('executeStatement');
|
||||
|
||||
$this->commandTester->execute(['--dry-run' => true]);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('DRY RUN', $output);
|
||||
$this->assertStringContainsString('Would archive 100 entries', $output);
|
||||
$this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testArchivesBatchesUntilComplete(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:00:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
|
||||
// First call: COUNT returns 150
|
||||
// Subsequent calls: archive_audit_entries returns batch counts
|
||||
$this->connection->method('fetchOne')
|
||||
->willReturnOnConsecutiveCalls(
|
||||
150, // COUNT query
|
||||
100, // First batch (full)
|
||||
50, // Second batch (partial, stops)
|
||||
);
|
||||
|
||||
$this->commandTester->execute(['--batch-size' => '100']);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('Successfully archived 150', $output);
|
||||
$this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testCustomRetentionYears(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:00:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
|
||||
$this->connection->method('fetchOne')->willReturn(0);
|
||||
|
||||
$this->commandTester->execute(['--retention-years' => '3']);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('(3 years retention)', $output);
|
||||
}
|
||||
|
||||
public function testCustomBatchSize(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:00:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
|
||||
// Return 500 entries to archive, then archive in 500-entry batches
|
||||
$this->connection->method('fetchOne')
|
||||
->willReturnOnConsecutiveCalls(
|
||||
500, // COUNT
|
||||
500, // First batch (equal to batch size)
|
||||
0, // Second batch (none left)
|
||||
);
|
||||
|
||||
$this->commandTester->execute(['--batch-size' => '500']);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('Successfully archived 500', $output);
|
||||
}
|
||||
|
||||
public function testShowsProgressBar(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:00:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
|
||||
$this->connection->method('fetchOne')
|
||||
->willReturnOnConsecutiveCalls(
|
||||
50, // COUNT
|
||||
50, // First batch
|
||||
);
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
|
||||
// Progress bar output includes percentage
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('Successfully archived 50', $output);
|
||||
}
|
||||
|
||||
public function testCalculatesCutoffDateCorrectly(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:00:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
|
||||
$capturedCutoff = null;
|
||||
$this->connection->method('fetchOne')
|
||||
->willReturnCallback(static function (string $sql, array $params) use (&$capturedCutoff) {
|
||||
if (str_contains($sql, 'COUNT')) {
|
||||
$capturedCutoff = $params['cutoff'];
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
$this->commandTester->execute(['--retention-years' => '5']);
|
||||
|
||||
// Cutoff should be 5 years before now (2021-02-03)
|
||||
$this->assertNotNull($capturedCutoff);
|
||||
$this->assertStringContainsString('2021-02-03', $capturedCutoff);
|
||||
}
|
||||
|
||||
public function testZeroBatchSizeReturnsFailure(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:00:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
|
||||
$this->commandTester->execute(['--batch-size' => '0']);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('Batch size must be a positive integer', $output);
|
||||
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testNegativeBatchSizeReturnsFailure(): void
|
||||
{
|
||||
$now = new DateTimeImmutable('2026-02-03 10:00:00');
|
||||
$this->clock->method('now')->willReturn($now);
|
||||
|
||||
$this->commandTester->execute(['--batch-size' => '-5']);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('Batch size must be a positive integer', $output);
|
||||
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testZeroRetentionYearsReturnsFailure(): void
|
||||
{
|
||||
$this->commandTester->execute(['--retention-years' => '0']);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('Retention years must be a positive integer', $output);
|
||||
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
|
||||
}
|
||||
|
||||
public function testNegativeRetentionYearsReturnsFailure(): void
|
||||
{
|
||||
$this->commandTester->execute(['--retention-years' => '-5']);
|
||||
|
||||
$output = $this->commandTester->getDisplay();
|
||||
$this->assertStringContainsString('Retention years must be a positive integer', $output);
|
||||
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Messenger;
|
||||
|
||||
use App\Shared\Domain\CorrelationId;
|
||||
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
|
||||
use App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware;
|
||||
use App\Shared\Infrastructure\Messenger\CorrelationIdStamp;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
|
||||
use Symfony\Component\Messenger\Middleware\StackInterface;
|
||||
|
||||
/**
|
||||
* @see Story 1.7 - T3: Correlation ID
|
||||
*/
|
||||
final class AddCorrelationIdStampMiddlewareTest extends TestCase
|
||||
{
|
||||
private AddCorrelationIdStampMiddleware $middleware;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->middleware = new AddCorrelationIdStampMiddleware();
|
||||
CorrelationIdHolder::clear();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
CorrelationIdHolder::clear();
|
||||
}
|
||||
|
||||
public function testAddsStampWhenCorrelationIdIsSet(): void
|
||||
{
|
||||
$correlationId = CorrelationId::generate();
|
||||
CorrelationIdHolder::set($correlationId);
|
||||
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$stack = $this->createCapturingStack();
|
||||
|
||||
$result = $this->middleware->handle($envelope, $stack);
|
||||
|
||||
$stamp = $result->last(CorrelationIdStamp::class);
|
||||
$this->assertInstanceOf(CorrelationIdStamp::class, $stamp);
|
||||
$this->assertSame($correlationId->value(), $stamp->correlationId);
|
||||
}
|
||||
|
||||
public function testDoesNotAddStampWhenNoCorrelationId(): void
|
||||
{
|
||||
CorrelationIdHolder::clear();
|
||||
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$stack = $this->createCapturingStack();
|
||||
|
||||
$result = $this->middleware->handle($envelope, $stack);
|
||||
|
||||
$stamp = $result->last(CorrelationIdStamp::class);
|
||||
$this->assertNull($stamp);
|
||||
}
|
||||
|
||||
public function testDoesNotOverwriteExistingStamp(): void
|
||||
{
|
||||
$existingId = '11111111-1111-1111-1111-111111111111';
|
||||
$currentId = CorrelationId::generate();
|
||||
CorrelationIdHolder::set($currentId);
|
||||
|
||||
$envelope = new Envelope(new stdClass(), [new CorrelationIdStamp($existingId)]);
|
||||
$stack = $this->createCapturingStack();
|
||||
|
||||
$result = $this->middleware->handle($envelope, $stack);
|
||||
|
||||
$stamp = $result->last(CorrelationIdStamp::class);
|
||||
$this->assertInstanceOf(CorrelationIdStamp::class, $stamp);
|
||||
// Should keep the existing stamp, not overwrite with current
|
||||
$this->assertSame($existingId, $stamp->correlationId);
|
||||
}
|
||||
|
||||
private function createCapturingStack(): StackInterface
|
||||
{
|
||||
return new class implements StackInterface {
|
||||
public function next(): MiddlewareInterface
|
||||
{
|
||||
return new class implements MiddlewareInterface {
|
||||
public function handle(Envelope $envelope, StackInterface $stack): Envelope
|
||||
{
|
||||
return $envelope;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Infrastructure\Messenger;
|
||||
|
||||
use App\Shared\Infrastructure\Audit\CorrelationIdHolder;
|
||||
use App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware;
|
||||
use App\Shared\Infrastructure\Messenger\CorrelationIdStamp;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use RuntimeException;
|
||||
use stdClass;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
|
||||
use Symfony\Component\Messenger\Middleware\StackInterface;
|
||||
|
||||
/**
|
||||
* @see Story 1.7 - T3: Correlation ID
|
||||
*/
|
||||
final class CorrelationIdMiddlewareTest extends TestCase
|
||||
{
|
||||
private CorrelationIdMiddleware $middleware;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->middleware = new CorrelationIdMiddleware();
|
||||
CorrelationIdHolder::clear();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
CorrelationIdHolder::clear();
|
||||
}
|
||||
|
||||
public function testUsesCorrelationIdFromStampWhenPresent(): void
|
||||
{
|
||||
$expectedId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
$envelope = new Envelope(new stdClass(), [new CorrelationIdStamp($expectedId)]);
|
||||
$stack = $this->createCapturingStack();
|
||||
|
||||
$this->middleware->handle($envelope, $stack);
|
||||
|
||||
// Verify the correlation ID was set during handling
|
||||
$this->assertSame($expectedId, $stack->capturedCorrelationId);
|
||||
}
|
||||
|
||||
public function testGeneratesNewCorrelationIdWhenNoStampAndNoExistingId(): void
|
||||
{
|
||||
// Async worker context: no existing ID, no stamp
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$stack = $this->createCapturingStack();
|
||||
|
||||
$this->middleware->handle($envelope, $stack);
|
||||
|
||||
// Should have generated a valid UUID
|
||||
$this->assertNotNull($stack->capturedCorrelationId);
|
||||
$this->assertTrue(Uuid::isValid($stack->capturedCorrelationId));
|
||||
}
|
||||
|
||||
public function testUsesExistingCorrelationIdDuringSynchronousDispatch(): void
|
||||
{
|
||||
// HTTP context: existing ID is set by HTTP middleware
|
||||
$existingId = '99999999-9999-9999-9999-999999999999';
|
||||
CorrelationIdHolder::set(\App\Shared\Domain\CorrelationId::fromString($existingId));
|
||||
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$stack = $this->createCapturingStack();
|
||||
|
||||
$this->middleware->handle($envelope, $stack);
|
||||
|
||||
// Should use the existing ID, not generate a new one
|
||||
$this->assertSame($existingId, $stack->capturedCorrelationId);
|
||||
}
|
||||
|
||||
public function testDoesNotClearCorrelationIdDuringSynchronousDispatch(): void
|
||||
{
|
||||
// HTTP context: existing ID should be preserved after dispatch
|
||||
$existingId = '99999999-9999-9999-9999-999999999999';
|
||||
CorrelationIdHolder::set(\App\Shared\Domain\CorrelationId::fromString($existingId));
|
||||
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$stack = $this->createPassthroughStack();
|
||||
|
||||
$this->middleware->handle($envelope, $stack);
|
||||
|
||||
// Should NOT be cleared - HTTP middleware handles that
|
||||
$this->assertNotNull(CorrelationIdHolder::get());
|
||||
$this->assertSame($existingId, CorrelationIdHolder::get()?->value());
|
||||
}
|
||||
|
||||
public function testClearsCorrelationIdInAsyncContext(): void
|
||||
{
|
||||
// Async worker context: no existing ID, no stamp
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$stack = $this->createPassthroughStack();
|
||||
|
||||
$this->middleware->handle($envelope, $stack);
|
||||
|
||||
// Should be cleared after handling in async context
|
||||
$this->assertNull(CorrelationIdHolder::get());
|
||||
}
|
||||
|
||||
public function testClearsCorrelationIdWhenStampPresent(): void
|
||||
{
|
||||
// Async worker receiving message from HTTP: has stamp
|
||||
$envelope = new Envelope(new stdClass(), [new CorrelationIdStamp('11111111-1111-1111-1111-111111111111')]);
|
||||
$stack = $this->createPassthroughStack();
|
||||
|
||||
$this->middleware->handle($envelope, $stack);
|
||||
|
||||
// Should be cleared after handling (async context)
|
||||
$this->assertNull(CorrelationIdHolder::get());
|
||||
}
|
||||
|
||||
public function testClearsCorrelationIdEvenOnException(): void
|
||||
{
|
||||
// Async worker context
|
||||
$envelope = new Envelope(new stdClass());
|
||||
$stack = $this->createThrowingStack();
|
||||
|
||||
try {
|
||||
$this->middleware->handle($envelope, $stack);
|
||||
$this->fail('Expected exception to be thrown');
|
||||
} catch (RuntimeException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
// Should be cleared even after exception in async context
|
||||
$this->assertNull(CorrelationIdHolder::get());
|
||||
}
|
||||
|
||||
public function testDoesNotLeakBetweenMessages(): void
|
||||
{
|
||||
$envelope1 = new Envelope(new stdClass(), [new CorrelationIdStamp('11111111-1111-1111-1111-111111111111')]);
|
||||
$envelope2 = new Envelope(new stdClass(), [new CorrelationIdStamp('22222222-2222-2222-2222-222222222222')]);
|
||||
|
||||
$stack1 = $this->createCapturingStack();
|
||||
$stack2 = $this->createCapturingStack();
|
||||
|
||||
$this->middleware->handle($envelope1, $stack1);
|
||||
$this->middleware->handle($envelope2, $stack2);
|
||||
|
||||
$this->assertSame('11111111-1111-1111-1111-111111111111', $stack1->capturedCorrelationId);
|
||||
$this->assertSame('22222222-2222-2222-2222-222222222222', $stack2->capturedCorrelationId);
|
||||
}
|
||||
|
||||
private function createCapturingStack(): CapturingStack
|
||||
{
|
||||
return new CapturingStack();
|
||||
}
|
||||
|
||||
private function createPassthroughStack(): StackInterface
|
||||
{
|
||||
return new class implements StackInterface {
|
||||
public function next(): MiddlewareInterface
|
||||
{
|
||||
return new class implements MiddlewareInterface {
|
||||
public function handle(Envelope $envelope, StackInterface $stack): Envelope
|
||||
{
|
||||
return $envelope;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function createThrowingStack(): StackInterface
|
||||
{
|
||||
return new class implements StackInterface {
|
||||
public function next(): MiddlewareInterface
|
||||
{
|
||||
return new class implements MiddlewareInterface {
|
||||
public function handle(Envelope $envelope, StackInterface $stack): Envelope
|
||||
{
|
||||
throw new RuntimeException('Handler failed');
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stack that captures the correlation ID during handling.
|
||||
*/
|
||||
final class CapturingStack implements StackInterface
|
||||
{
|
||||
public ?string $capturedCorrelationId = null;
|
||||
|
||||
public function next(): MiddlewareInterface
|
||||
{
|
||||
return new CapturingMiddleware($this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class CapturingMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(private CapturingStack $stack)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(Envelope $envelope, StackInterface $stack): Envelope
|
||||
{
|
||||
$this->stack->capturedCorrelationId = CorrelationIdHolder::get()?->value();
|
||||
|
||||
return $envelope;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user