From 2ed60fdcc183cf5742f9042807727a03b2e46fdc Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Wed, 4 Feb 2026 00:11:58 +0100 Subject: [PATCH] feat: Audit trail pour actions sensibles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ci.yml | 7 + .gitignore | 3 + Makefile | 7 +- backend/config/packages/messenger.yaml | 8 + backend/config/services.yaml | 13 +- backend/migrations/Version20260203100000.php | 65 ++++ backend/migrations/Version20260203100001.php | 118 ++++++ .../Event/CompteBloqueTemporairement.php | 2 + .../Domain/Event/ConnexionEchouee.php | 2 + .../Messaging/AuditLoginEventsHandler.php | 76 ---- .../Security/LoginFailureHandler.php | 35 +- .../Shared/Application/Port/AuditLogger.php | 65 ++++ backend/src/Shared/Domain/CorrelationId.php | 9 + .../Infrastructure/Audit/AuditLogEntry.php | 101 +++++ .../Audit/AuditLogRepository.php | 188 ++++++++++ .../Infrastructure/Audit/AuditLogger.php | 215 +++++++++++ .../Audit/CorrelationIdHolder.php | 62 +++ .../Handler/AuditAuthenticationHandler.php | 106 ++++++ .../Console/ArchiveAuditLogsCommand.php | 181 +++++++++ .../AddCorrelationIdStampMiddleware.php | 35 ++ .../Messenger/CorrelationIdMiddleware.php | 60 +++ .../Messenger/CorrelationIdStamp.php | 22 ++ .../Middleware/CorrelationIdMiddleware.php | 113 ++++++ .../Api/ActivationEndpointsTest.php | 102 +++++ .../Api/RefreshTokenEndpointTest.php | 105 ++++++ .../Api/Controller/LogoutControllerTest.php | 305 +++++++++++++++ .../InMemoryRefreshTokenRepositoryTest.php | 249 ++++++++++++ .../Security/LoginSuccessHandlerTest.php | 331 ++++++++++++++++ .../Unit/Shared/Domain/CorrelationIdTest.php | 16 + .../Audit/AuditLogEntryTest.php | 81 ++++ .../Audit/AuditLogRepositoryTest.php | 219 +++++++++++ .../Infrastructure/Audit/AuditLoggerTest.php | 354 ++++++++++++++++++ .../Audit/CorrelationIdHolderTest.php | 87 +++++ .../Audit/CorrelationIdMiddlewareTest.php | 188 ++++++++++ .../AuditAuthenticationHandlerTest.php | 200 ++++++++++ .../Console/ArchiveAuditLogsCommandTest.php | 224 +++++++++++ .../AddCorrelationIdStampMiddlewareTest.php | 94 +++++ .../Messenger/CorrelationIdMiddlewareTest.php | 212 +++++++++++ 38 files changed, 4179 insertions(+), 81 deletions(-) create mode 100644 backend/migrations/Version20260203100000.php create mode 100644 backend/migrations/Version20260203100001.php delete mode 100644 backend/src/Administration/Infrastructure/Messaging/AuditLoginEventsHandler.php create mode 100644 backend/src/Shared/Application/Port/AuditLogger.php create mode 100644 backend/src/Shared/Infrastructure/Audit/AuditLogEntry.php create mode 100644 backend/src/Shared/Infrastructure/Audit/AuditLogRepository.php create mode 100644 backend/src/Shared/Infrastructure/Audit/AuditLogger.php create mode 100644 backend/src/Shared/Infrastructure/Audit/CorrelationIdHolder.php create mode 100644 backend/src/Shared/Infrastructure/Audit/Handler/AuditAuthenticationHandler.php create mode 100644 backend/src/Shared/Infrastructure/Console/ArchiveAuditLogsCommand.php create mode 100644 backend/src/Shared/Infrastructure/Messenger/AddCorrelationIdStampMiddleware.php create mode 100644 backend/src/Shared/Infrastructure/Messenger/CorrelationIdMiddleware.php create mode 100644 backend/src/Shared/Infrastructure/Messenger/CorrelationIdStamp.php create mode 100644 backend/src/Shared/Infrastructure/Middleware/CorrelationIdMiddleware.php create mode 100644 backend/tests/Functional/Administration/Api/ActivationEndpointsTest.php create mode 100644 backend/tests/Functional/Administration/Api/RefreshTokenEndpointTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Api/Controller/LogoutControllerTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Persistence/InMemory/InMemoryRefreshTokenRepositoryTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Security/LoginSuccessHandlerTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Audit/AuditLogEntryTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Audit/AuditLogRepositoryTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Audit/AuditLoggerTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Audit/CorrelationIdHolderTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Audit/CorrelationIdMiddlewareTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Audit/Handler/AuditAuthenticationHandlerTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Console/ArchiveAuditLogsCommandTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Messenger/AddCorrelationIdStampMiddlewareTest.php create mode 100644 backend/tests/Unit/Shared/Infrastructure/Messenger/CorrelationIdMiddlewareTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a54619..7d0ddb8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 7148b70..b49ba46 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* +correlation_id +tenant_id +test-results/ diff --git a/Makefile b/Makefile index 9b41330..3b00bc7 100644 --- a/Makefile +++ b/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) diff --git a/backend/config/packages/messenger.yaml b/backend/config/packages/messenger.yaml index 722366e..cb54532 100644 --- a/backend/config/packages/messenger.yaml +++ b/backend/config/packages/messenger.yaml @@ -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 diff --git a/backend/config/services.yaml b/backend/config/services.yaml index e0f45b1..4662edf 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -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 diff --git a/backend/migrations/Version20260203100000.php b/backend/migrations/Version20260203100000.php new file mode 100644 index 0000000..4bfe91f --- /dev/null +++ b/backend/migrations/Version20260203100000.php @@ -0,0 +1,65 @@ +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'); + } +} diff --git a/backend/migrations/Version20260203100001.php b/backend/migrations/Version20260203100001.php new file mode 100644 index 0000000..e6aaa9e --- /dev/null +++ b/backend/migrations/Version20260203100001.php @@ -0,0 +1,118 @@ +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'); + } +} diff --git a/backend/src/Administration/Domain/Event/CompteBloqueTemporairement.php b/backend/src/Administration/Domain/Event/CompteBloqueTemporairement.php index a46c9da..23b5ada 100644 --- a/backend/src/Administration/Domain/Event/CompteBloqueTemporairement.php +++ b/backend/src/Administration/Domain/Event/CompteBloqueTemporairement.php @@ -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, diff --git a/backend/src/Administration/Domain/Event/ConnexionEchouee.php b/backend/src/Administration/Domain/Event/ConnexionEchouee.php index 0eaf991..13733b4 100644 --- a/backend/src/Administration/Domain/Event/ConnexionEchouee.php +++ b/backend/src/Administration/Domain/Event/ConnexionEchouee.php @@ -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' diff --git a/backend/src/Administration/Infrastructure/Messaging/AuditLoginEventsHandler.php b/backend/src/Administration/Infrastructure/Messaging/AuditLoginEventsHandler.php deleted file mode 100644 index 99f8aed..0000000 --- a/backend/src/Administration/Infrastructure/Messaging/AuditLoginEventsHandler.php +++ /dev/null @@ -1,76 +0,0 @@ -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); - } -} diff --git a/backend/src/Administration/Infrastructure/Security/LoginFailureHandler.php b/backend/src/Administration/Infrastructure/Security/LoginFailureHandler.php index 00e8012..cddd254 100644 --- a/backend/src/Administration/Infrastructure/Security/LoginFailureHandler.php +++ b/backend/src/Administration/Infrastructure/Security/LoginFailureHandler.php @@ -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; + } + } } diff --git a/backend/src/Shared/Application/Port/AuditLogger.php b/backend/src/Shared/Application/Port/AuditLogger.php new file mode 100644 index 0000000..8d083a3 --- /dev/null +++ b/backend/src/Shared/Application/Port/AuditLogger.php @@ -0,0 +1,65 @@ + $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 $oldValues Previous values + * @param array $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 $context Additional context (screen, action) + */ + public function logAccess( + string $resourceType, + UuidInterface $resourceId, + array $context = [], + ): void; +} diff --git a/backend/src/Shared/Domain/CorrelationId.php b/backend/src/Shared/Domain/CorrelationId.php index 08d0fc0..e500ca8 100644 --- a/backend/src/Shared/Domain/CorrelationId.php +++ b/backend/src/Shared/Domain/CorrelationId.php @@ -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); } diff --git a/backend/src/Shared/Infrastructure/Audit/AuditLogEntry.php b/backend/src/Shared/Infrastructure/Audit/AuditLogEntry.php new file mode 100644 index 0000000..675f2d9 --- /dev/null +++ b/backend/src/Shared/Infrastructure/Audit/AuditLogEntry.php @@ -0,0 +1,101 @@ + $payload + * @param array $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 $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 $payload */ + $payload = json_decode($payloadJson, true, 512, JSON_THROW_ON_ERROR); + /** @var array $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; + } +} diff --git a/backend/src/Shared/Infrastructure/Audit/AuditLogRepository.php b/backend/src/Shared/Infrastructure/Audit/AuditLogRepository.php new file mode 100644 index 0000000..71a938f --- /dev/null +++ b/backend/src/Shared/Infrastructure/Audit/AuditLogRepository.php @@ -0,0 +1,188 @@ + + */ + 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 + */ + 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 + */ + 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 + */ + 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> $rows + * + * @return list + */ + private function mapResults(array $rows): array + { + return array_map( + static fn (array $row): AuditLogEntry => AuditLogEntry::fromDatabaseRow($row), + $rows, + ); + } +} diff --git a/backend/src/Shared/Infrastructure/Audit/AuditLogger.php b/backend/src/Shared/Infrastructure/Audit/AuditLogger.php new file mode 100644 index 0000000..6907664 --- /dev/null +++ b/backend/src/Shared/Infrastructure/Audit/AuditLogger.php @@ -0,0 +1,215 @@ +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 $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 + */ + 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); + } +} diff --git a/backend/src/Shared/Infrastructure/Audit/CorrelationIdHolder.php b/backend/src/Shared/Infrastructure/Audit/CorrelationIdHolder.php new file mode 100644 index 0000000..3dd1f42 --- /dev/null +++ b/backend/src/Shared/Infrastructure/Audit/CorrelationIdHolder.php @@ -0,0 +1,62 @@ +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); + } +} diff --git a/backend/src/Shared/Infrastructure/Console/ArchiveAuditLogsCommand.php b/backend/src/Shared/Infrastructure/Console/ArchiveAuditLogsCommand.php new file mode 100644 index 0000000..01c039c --- /dev/null +++ b/backend/src/Shared/Infrastructure/Console/ArchiveAuditLogsCommand.php @@ -0,0 +1,181 @@ +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; + } +} diff --git a/backend/src/Shared/Infrastructure/Messenger/AddCorrelationIdStampMiddleware.php b/backend/src/Shared/Infrastructure/Messenger/AddCorrelationIdStampMiddleware.php new file mode 100644 index 0000000..838b12d --- /dev/null +++ b/backend/src/Shared/Infrastructure/Messenger/AddCorrelationIdStampMiddleware.php @@ -0,0 +1,35 @@ +last(CorrelationIdStamp::class) === null) { + $correlationId = CorrelationIdHolder::get(); + if ($correlationId !== null) { + $envelope = $envelope->with(new CorrelationIdStamp($correlationId->value())); + } + } + + return $stack->next()->handle($envelope, $stack); + } +} diff --git a/backend/src/Shared/Infrastructure/Messenger/CorrelationIdMiddleware.php b/backend/src/Shared/Infrastructure/Messenger/CorrelationIdMiddleware.php new file mode 100644 index 0000000..3988df7 --- /dev/null +++ b/backend/src/Shared/Infrastructure/Messenger/CorrelationIdMiddleware.php @@ -0,0 +1,60 @@ +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(); + } + } + } +} diff --git a/backend/src/Shared/Infrastructure/Messenger/CorrelationIdStamp.php b/backend/src/Shared/Infrastructure/Messenger/CorrelationIdStamp.php new file mode 100644 index 0000000..bd2b1ad --- /dev/null +++ b/backend/src/Shared/Infrastructure/Messenger/CorrelationIdStamp.php @@ -0,0 +1,22 @@ +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(); + } + } +} diff --git a/backend/tests/Functional/Administration/Api/ActivationEndpointsTest.php b/backend/tests/Functional/Administration/Api/ActivationEndpointsTest.php new file mode 100644 index 0000000..93a0bf2 --- /dev/null +++ b/backend/tests/Functional/Administration/Api/ActivationEndpointsTest.php @@ -0,0 +1,102 @@ +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); + } +} diff --git a/backend/tests/Functional/Administration/Api/RefreshTokenEndpointTest.php b/backend/tests/Functional/Administration/Api/RefreshTokenEndpointTest.php new file mode 100644 index 0000000..ef85e5d --- /dev/null +++ b/backend/tests/Functional/Administration/Api/RefreshTokenEndpointTest.php @@ -0,0 +1,105 @@ +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); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Controller/LogoutControllerTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Controller/LogoutControllerTest.php new file mode 100644 index 0000000..37b3508 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Controller/LogoutControllerTest.php @@ -0,0 +1,305 @@ +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(), + ); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Persistence/InMemory/InMemoryRefreshTokenRepositoryTest.php b/backend/tests/Unit/Administration/Infrastructure/Persistence/InMemory/InMemoryRefreshTokenRepositoryTest.php new file mode 100644 index 0000000..3891760 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Persistence/InMemory/InMemoryRefreshTokenRepositoryTest.php @@ -0,0 +1,249 @@ +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; + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Security/LoginSuccessHandlerTest.php b/backend/tests/Unit/Administration/Infrastructure/Security/LoginSuccessHandlerTest.php new file mode 100644 index 0000000..29dead9 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Security/LoginSuccessHandlerTest.php @@ -0,0 +1,331 @@ +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'], + ); + } +} diff --git a/backend/tests/Unit/Shared/Domain/CorrelationIdTest.php b/backend/tests/Unit/Shared/Domain/CorrelationIdTest.php index eadc1c3..12ec7ed 100644 --- a/backend/tests/Unit/Shared/Domain/CorrelationIdTest.php +++ b/backend/tests/Unit/Shared/Domain/CorrelationIdTest.php @@ -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(''); + } } diff --git a/backend/tests/Unit/Shared/Infrastructure/Audit/AuditLogEntryTest.php b/backend/tests/Unit/Shared/Infrastructure/Audit/AuditLogEntryTest.php new file mode 100644 index 0000000..72a3a41 --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Audit/AuditLogEntryTest.php @@ -0,0 +1,81 @@ +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()); + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Audit/AuditLogRepositoryTest.php b/backend/tests/Unit/Shared/Infrastructure/Audit/AuditLogRepositoryTest.php new file mode 100644 index 0000000..c8946a0 --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Audit/AuditLogRepositoryTest.php @@ -0,0 +1,219 @@ +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 + */ + 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> $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; + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Audit/AuditLoggerTest.php b/backend/tests/Unit/Shared/Infrastructure/Audit/AuditLoggerTest.php new file mode 100644 index 0000000..1eb4aa7 --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Audit/AuditLoggerTest.php @@ -0,0 +1,354 @@ +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); + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Audit/CorrelationIdHolderTest.php b/backend/tests/Unit/Shared/Infrastructure/Audit/CorrelationIdHolderTest.php new file mode 100644 index 0000000..6e690a2 --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Audit/CorrelationIdHolderTest.php @@ -0,0 +1,87 @@ +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); + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Audit/CorrelationIdMiddlewareTest.php b/backend/tests/Unit/Shared/Infrastructure/Audit/CorrelationIdMiddlewareTest.php new file mode 100644 index 0000000..039eadd --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Audit/CorrelationIdMiddlewareTest.php @@ -0,0 +1,188 @@ +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); + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Audit/Handler/AuditAuthenticationHandlerTest.php b/backend/tests/Unit/Shared/Infrastructure/Audit/Handler/AuditAuthenticationHandlerTest.php new file mode 100644 index 0000000..ad775ee --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Audit/Handler/AuditAuthenticationHandlerTest.php @@ -0,0 +1,200 @@ +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']); + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Console/ArchiveAuditLogsCommandTest.php b/backend/tests/Unit/Shared/Infrastructure/Console/ArchiveAuditLogsCommandTest.php new file mode 100644 index 0000000..2873b27 --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Console/ArchiveAuditLogsCommandTest.php @@ -0,0 +1,224 @@ +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()); + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Messenger/AddCorrelationIdStampMiddlewareTest.php b/backend/tests/Unit/Shared/Infrastructure/Messenger/AddCorrelationIdStampMiddlewareTest.php new file mode 100644 index 0000000..426c85f --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Messenger/AddCorrelationIdStampMiddlewareTest.php @@ -0,0 +1,94 @@ +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; + } + }; + } + }; + } +} diff --git a/backend/tests/Unit/Shared/Infrastructure/Messenger/CorrelationIdMiddlewareTest.php b/backend/tests/Unit/Shared/Infrastructure/Messenger/CorrelationIdMiddlewareTest.php new file mode 100644 index 0000000..d47befd --- /dev/null +++ b/backend/tests/Unit/Shared/Infrastructure/Messenger/CorrelationIdMiddlewareTest.php @@ -0,0 +1,212 @@ +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; + } +}