From c5e6c1d8107c72c20a4829b31977fbee16061fd4 Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Sat, 31 Jan 2026 18:00:43 +0100 Subject: [PATCH] feat: Activation de compte utilisateur avec validation token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'inscription Classeo se fait via invitation : un admin crée un compte, l'utilisateur reçoit un lien d'activation par email pour définir son mot de passe. Ce flow sécurisé évite les inscriptions non autorisées et garantit que seuls les utilisateurs légitimes accèdent au système. Points clés de l'implémentation : - Tokens d'activation à usage unique stockés en cache (Redis/filesystem) - Validation du consentement parental pour les mineurs < 15 ans (RGPD) - L'échec d'activation ne consume pas le token (retry possible) - Users dans un cache séparé sans TTL (pas d'expiration) - Hot reload en dev (FrankenPHP sans mode worker) Story: 1.3 - Inscription et activation de compte --- .github/workflows/ci.yml | 69 +- Makefile | 36 +- backend/.env | 9 + backend/Dockerfile | 5 +- backend/composer.json | 2 + backend/composer.lock | 387 ++++++++++- backend/config/bundles.php | 3 +- backend/config/packages/cache.yaml | 19 + backend/config/packages/mailer.yaml | 3 + backend/config/packages/nelmio_cors.yaml | 10 + backend/config/packages/security.yaml | 9 + backend/config/services.yaml | 33 + .../ActivateAccountCommand.php | 20 + .../ActivateAccountHandler.php | 54 ++ .../ActivateAccount/ActivateAccountResult.php | 25 + .../Application/Port/PasswordHasher.php | 25 + .../Domain/Event/ActivationTokenGenerated.php | 36 + .../Domain/Event/ActivationTokenUsed.php | 33 + .../Domain/Event/CompteActive.php | 42 ++ .../Domain/Event/CompteCreated.php | 39 ++ .../ActivationTokenAlreadyUsedException.php | 21 + .../ActivationTokenExpiredException.php | 21 + .../ActivationTokenNotFoundException.php | 29 + .../Exception/CompteNonActivableException.php | 31 + .../Exception/EmailInvalideException.php | 20 + .../Exception/UserNotFoundException.php | 30 + .../Model/ActivationToken/ActivationToken.php | 149 ++++ .../ActivationToken/ActivationTokenId.php | 11 + .../ConsentementParental.php | 46 ++ .../Domain/Model/User/Email.php | 39 ++ .../Administration/Domain/Model/User/Role.php | 74 ++ .../Domain/Model/User/StatutCompte.php | 33 + .../Administration/Domain/Model/User/User.php | 173 +++++ .../Domain/Model/User/UserId.php | 11 + .../Policy/ConsentementParentalPolicy.php | 48 ++ .../Repository/ActivationTokenRepository.php | 35 + .../Domain/Repository/UserRepository.php | 21 + .../Processor/ActivateAccountProcessor.php | 96 +++ .../Provider/ActivationTokenInfoProvider.php | 62 ++ .../Api/Resource/ActivateAccountInput.php | 49 ++ .../Api/Resource/ActivateAccountOutput.php | 19 + .../Api/Resource/ActivationTokenInfo.php | 38 + .../CreateTestActivationTokenCommand.php | 134 ++++ .../SendActivationConfirmationHandler.php | 50 ++ .../Persistence/Cache/CacheUserRepository.php | 162 +++++ .../InMemoryActivationTokenRepository.php | 75 ++ .../InMemory/InMemoryUserRepository.php | 46 ++ .../Redis/RedisActivationTokenRepository.php | 142 ++++ .../Security/SymfonyPasswordHasher.php | 40 ++ .../Tenant/TenantMiddleware.php | 3 + backend/symfony.lock | 24 + .../emails/activation_confirmation.html.twig | 113 +++ .../ActivateAccountHandlerTest.php | 182 +++++ .../ActivationToken/ActivationTokenIdTest.php | 50 ++ .../ActivationToken/ActivationTokenTest.php | 219 ++++++ .../Domain/Model/User/UserTest.php | 220 ++++++ .../Policy/ConsentementParentalPolicyTest.php | 105 +++ .../ActivateAccountProcessorTest.php | 190 +++++ .../Cache/CacheUserRepositoryTest.php | 160 +++++ .../InMemoryActivationTokenRepositoryTest.php | 121 ++++ frontend/.gitignore | 2 + frontend/e2e/activation.spec.ts | 187 +++++ frontend/e2e/global-setup.ts | 52 ++ frontend/e2e/test-utils.ts | 22 + frontend/eslint.config.js | 5 +- frontend/playwright.config.ts | 20 +- frontend/src/lib/types/activation.ts | 41 ++ .../src/routes/activate/[token]/+page.svelte | 649 ++++++++++++++++++ frontend/src/routes/login/+page.svelte | 257 +++++++ 69 files changed, 5173 insertions(+), 13 deletions(-) create mode 100644 backend/config/packages/mailer.yaml create mode 100644 backend/config/packages/nelmio_cors.yaml create mode 100644 backend/src/Administration/Application/Command/ActivateAccount/ActivateAccountCommand.php create mode 100644 backend/src/Administration/Application/Command/ActivateAccount/ActivateAccountHandler.php create mode 100644 backend/src/Administration/Application/Command/ActivateAccount/ActivateAccountResult.php create mode 100644 backend/src/Administration/Application/Port/PasswordHasher.php create mode 100644 backend/src/Administration/Domain/Event/ActivationTokenGenerated.php create mode 100644 backend/src/Administration/Domain/Event/ActivationTokenUsed.php create mode 100644 backend/src/Administration/Domain/Event/CompteActive.php create mode 100644 backend/src/Administration/Domain/Event/CompteCreated.php create mode 100644 backend/src/Administration/Domain/Exception/ActivationTokenAlreadyUsedException.php create mode 100644 backend/src/Administration/Domain/Exception/ActivationTokenExpiredException.php create mode 100644 backend/src/Administration/Domain/Exception/ActivationTokenNotFoundException.php create mode 100644 backend/src/Administration/Domain/Exception/CompteNonActivableException.php create mode 100644 backend/src/Administration/Domain/Exception/EmailInvalideException.php create mode 100644 backend/src/Administration/Domain/Exception/UserNotFoundException.php create mode 100644 backend/src/Administration/Domain/Model/ActivationToken/ActivationToken.php create mode 100644 backend/src/Administration/Domain/Model/ActivationToken/ActivationTokenId.php create mode 100644 backend/src/Administration/Domain/Model/ConsentementParental/ConsentementParental.php create mode 100644 backend/src/Administration/Domain/Model/User/Email.php create mode 100644 backend/src/Administration/Domain/Model/User/Role.php create mode 100644 backend/src/Administration/Domain/Model/User/StatutCompte.php create mode 100644 backend/src/Administration/Domain/Model/User/User.php create mode 100644 backend/src/Administration/Domain/Model/User/UserId.php create mode 100644 backend/src/Administration/Domain/Policy/ConsentementParentalPolicy.php create mode 100644 backend/src/Administration/Domain/Repository/ActivationTokenRepository.php create mode 100644 backend/src/Administration/Domain/Repository/UserRepository.php create mode 100644 backend/src/Administration/Infrastructure/Api/Processor/ActivateAccountProcessor.php create mode 100644 backend/src/Administration/Infrastructure/Api/Provider/ActivationTokenInfoProvider.php create mode 100644 backend/src/Administration/Infrastructure/Api/Resource/ActivateAccountInput.php create mode 100644 backend/src/Administration/Infrastructure/Api/Resource/ActivateAccountOutput.php create mode 100644 backend/src/Administration/Infrastructure/Api/Resource/ActivationTokenInfo.php create mode 100644 backend/src/Administration/Infrastructure/Console/CreateTestActivationTokenCommand.php create mode 100644 backend/src/Administration/Infrastructure/Messaging/SendActivationConfirmationHandler.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/Cache/CacheUserRepository.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryActivationTokenRepository.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryUserRepository.php create mode 100644 backend/src/Administration/Infrastructure/Persistence/Redis/RedisActivationTokenRepository.php create mode 100644 backend/src/Administration/Infrastructure/Security/SymfonyPasswordHasher.php create mode 100644 backend/templates/emails/activation_confirmation.html.twig create mode 100644 backend/tests/Unit/Administration/Application/Command/ActivateAccount/ActivateAccountHandlerTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/ActivationToken/ActivationTokenIdTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/ActivationToken/ActivationTokenTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Model/User/UserTest.php create mode 100644 backend/tests/Unit/Administration/Domain/Policy/ConsentementParentalPolicyTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Api/Processor/ActivateAccountProcessorTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Persistence/Cache/CacheUserRepositoryTest.php create mode 100644 backend/tests/Unit/Administration/Infrastructure/Persistence/InMemory/InMemoryActivationTokenRepositoryTest.php create mode 100644 frontend/e2e/activation.spec.ts create mode 100644 frontend/e2e/global-setup.ts create mode 100644 frontend/e2e/test-utils.ts create mode 100644 frontend/src/lib/types/activation.ts create mode 100644 frontend/src/routes/activate/[token]/+page.svelte create mode 100644 frontend/src/routes/login/+page.svelte diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e669a2..e11b883 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,11 +128,74 @@ jobs: - name: Run unit tests run: pnpm run test + # ============================================================================= + # E2E Tests - Playwright with Docker backend + # ============================================================================= + test-e2e: + name: E2E Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 9 + + - name: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache pnpm dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: ${{ runner.os }}-pnpm-store- + + - name: Install frontend dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + - name: Install Playwright browsers + working-directory: frontend run: pnpm exec playwright install --with-deps + - name: Build and start backend services + run: | + # Build images first (with Docker layer caching) + docker compose build php + # Start services (includes db, redis, rabbitmq dependencies) + docker compose up -d php + timeout-minutes: 10 + + - name: Wait for backend to be ready + run: | + echo "Waiting for backend to be ready (composer install + app startup)..." + # Wait up to 5 minutes for the backend to respond + timeout 300 bash -c 'until curl -sf http://localhost:18000/api > /dev/null 2>&1; do + echo "Waiting for backend..." + sleep 5 + done' + echo "Backend is ready!" + + - name: Show backend logs on failure + if: failure() + run: docker compose logs php + - name: Run E2E tests + working-directory: frontend run: pnpm run test:e2e + env: + # Frontend serves on 4173 (preview mode), backend on 18000 (Docker) + PUBLIC_API_PORT: "18000" + PUBLIC_API_URL: http://localhost:18000/api - name: Upload Playwright report uses: actions/upload-artifact@v4 @@ -142,6 +205,10 @@ jobs: path: frontend/playwright-report/ retention-days: 7 + - name: Stop backend services + if: always() + run: docker compose down + # ============================================================================= # Naming Conventions Check # ============================================================================= @@ -161,7 +228,7 @@ jobs: build: name: Build Check runs-on: ubuntu-latest - needs: [test-backend, test-frontend] + needs: [test-backend, test-frontend, test-e2e] steps: - uses: actions/checkout@v4 diff --git a/Makefile b/Makefile index 07d0e76..48a5968 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help up down restart rebuild logs ps test lint phpstan arch cs-fix warmup frontend-lint frontend-test e2e clean +.PHONY: help up down restart rebuild logs ps test lint phpstan arch cs-fix warmup frontend-lint frontend-test e2e clean shell bash console # Default target help: @@ -14,6 +14,12 @@ help: @echo " make ps - Statut des services" @echo " make clean - Supprimer volumes et images" @echo "" + @echo "Shell:" + @echo " make shell - Shell bash dans le container PHP" + @echo " make bash - Alias pour make shell" + @echo " make console - Console Symfony (ex: make console c='debug:router')" + @echo " make shell-frontend - Shell dans le container frontend" + @echo "" @echo "Backend:" @echo " make phpstan - Analyse statique PHPStan" @echo " make arch - Tests d'architecture (PHPat)" @@ -61,6 +67,21 @@ ps: clean: docker compose down -v --rmi local +# ============================================================================= +# Shell +# ============================================================================= + +shell: + docker compose exec php sh + +bash: shell + +console: + docker compose exec php php bin/console $(c) + +shell-frontend: + docker compose exec frontend sh + # ============================================================================= # Backend # ============================================================================= @@ -119,3 +140,16 @@ check-naming: check-tenants: ./scripts/check-tenants.sh + +# ============================================================================= +# Dev helpers +# ============================================================================= + +# Creer un token d'activation de test +# Usage: make token [email=user@test.com] [role=PARENT] [minor=1] +token: + docker compose exec php php bin/console app:dev:create-test-activation-token \ + $(if $(email),--email=$(email),) \ + $(if $(role),--role=$(role),) \ + $(if $(minor),--minor,) \ + --base-url=http://localhost:5174 diff --git a/backend/.env b/backend/.env index b1a9c03..084df8c 100644 --- a/backend/.env +++ b/backend/.env @@ -63,3 +63,12 @@ DEFAULT_URI=http://localhost # Base domain for tenant resolution (e.g., classeo.fr, classeo.local) TENANT_BASE_DOMAIN=classeo.local ###< multi-tenant ### + +###> app ### +# Frontend URL for emails and links +APP_URL=http://localhost:5173 +###< app ### + +###> nelmio/cors-bundle ### +CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' +###< nelmio/cors-bundle ### diff --git a/backend/Dockerfile b/backend/Dockerfile index 4b8fc84..697fbe0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -74,7 +74,10 @@ RUN echo "xdebug.mode=develop,debug,coverage" >> "$PHP_INI_DIR/conf.d/xdebug.ini # Caddy config for FrankenPHP ENV SERVER_NAME=:8000 -ENV FRANKENPHP_CONFIG="worker ./public/index.php" +# In dev mode, we do NOT use worker mode to enable automatic file reloading +# Each request loads PHP fresh, so code changes are picked up immediately +# Worker mode is only used in production for performance +ENV FRANKENPHP_CONFIG="" # Entrypoint: detect host UID/GID and run as matching user # Uses gosu with UID:GID directly (no need to create user in Dockerfile) diff --git a/backend/composer.json b/backend/composer.json index 4b0403c..ac51f87 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -16,6 +16,7 @@ "doctrine/doctrine-migrations-bundle": "^3.4", "doctrine/orm": "^3.3", "lexik/jwt-authentication-bundle": "^3.2", + "nelmio/cors-bundle": "^2.6", "ramsey/uuid": "^4.7", "symfony/amqp-messenger": "^8.0", "symfony/asset": "^8.0", @@ -24,6 +25,7 @@ "symfony/dotenv": "^8.0", "symfony/flex": "^2", "symfony/framework-bundle": "^8.0", + "symfony/mailer": "8.0.*", "symfony/messenger": "^8.0", "symfony/monolog-bundle": "^4.0", "symfony/property-access": "^8.0", diff --git a/backend/composer.lock b/backend/composer.lock index e92ebf7..080ffd7 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5db4139b65c041189bc59e0582d6f82d", + "content-hash": "e5abd2128a53127e2298b296ed587025", "packages": [ { "name": "api-platform/core", @@ -1390,6 +1390,73 @@ }, "time": "2025-10-26T09:35:14+00:00" }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, { "name": "lcobucci/jwt", "version": "5.6.0", @@ -1682,6 +1749,71 @@ ], "time": "2026-01-02T08:56:05+00:00" }, + { + "name": "nelmio/cors-bundle", + "version": "2.6.1", + "source": { + "type": "git", + "url": "https://github.com/nelmio/NelmioCorsBundle.git", + "reference": "3d80dbcd5d1eb5f8b20ed5199e1778d44c2e4d1c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nelmio/NelmioCorsBundle/zipball/3d80dbcd5d1eb5f8b20ed5199e1778d44c2e4d1c", + "reference": "3d80dbcd5d1eb5f8b20ed5199e1778d44c2e4d1c", + "shasum": "" + }, + "require": { + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11.5", + "phpstan/phpstan-deprecation-rules": "^1.2.0", + "phpstan/phpstan-phpunit": "^1.4", + "phpstan/phpstan-symfony": "^1.4.4", + "phpunit/phpunit": "^8" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Nelmio\\CorsBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nelmio", + "homepage": "http://nelm.io" + }, + { + "name": "Symfony Community", + "homepage": "https://github.com/nelmio/NelmioCorsBundle/contributors" + } + ], + "description": "Adds CORS (Cross-Origin Resource Sharing) headers support in your Symfony application", + "keywords": [ + "api", + "cors", + "crossdomain" + ], + "support": { + "issues": "https://github.com/nelmio/NelmioCorsBundle/issues", + "source": "https://github.com/nelmio/NelmioCorsBundle/tree/2.6.1" + }, + "time": "2026-01-12T15:59:08+00:00" + }, { "name": "psr/cache", "version": "3.0.0", @@ -3883,6 +4015,86 @@ ], "time": "2026-01-28T10:46:31+00:00" }, + { + "name": "symfony/mailer", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "a074d353f5b5a81d356652e8a2034fdd0501420b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/a074d353f5b5a81d356652e8a2034fdd0501420b", + "reference": "a074d353f5b5a81d356652e8a2034fdd0501420b", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.4", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-08T08:40:07+00:00" + }, { "name": "symfony/messenger", "version": "v8.0.4", @@ -3973,6 +4185,92 @@ ], "time": "2026-01-08T22:36:47+00:00" }, + { + "name": "symfony/mime", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "543d01b6ee4b8eb80ce9349186ad530eb8704252" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/543d01b6ee4b8eb80ce9349186ad530eb8704252", + "reference": "543d01b6ee4b8eb80ce9349186ad530eb8704252", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/type-resolver": "<1.5.1" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v8.0.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-27T09:06:10+00:00" + }, { "name": "symfony/monolog-bridge", "version": "v8.0.4", @@ -4284,6 +4582,93 @@ ], "time": "2025-06-27T09:58:17+00:00" }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-10T14:38:51+00:00" + }, { "name": "symfony/polyfill-intl-normalizer", "version": "v1.33.0", diff --git a/backend/config/bundles.php b/backend/config/bundles.php index de7982b..50df428 100644 --- a/backend/config/bundles.php +++ b/backend/config/bundles.php @@ -1,7 +1,5 @@ ['all' => true], Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], @@ -15,4 +13,5 @@ return [ Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], + Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], ]; diff --git a/backend/config/packages/cache.yaml b/backend/config/packages/cache.yaml index 46b81b1..f1f74bb 100644 --- a/backend/config/packages/cache.yaml +++ b/backend/config/packages/cache.yaml @@ -3,6 +3,17 @@ framework: # Unique name of your app: used to compute stable namespaces for cache keys. prefix_seed: classeo/backend + pools: + # Pool dédié aux tokens d'activation (7 jours TTL) + activation_tokens.cache: + adapter: cache.adapter.filesystem + default_lifetime: 604800 # 7 jours + + # Pool dédié aux utilisateurs (pas de TTL - données persistantes) + users.cache: + adapter: cache.adapter.filesystem + default_lifetime: 0 # Pas d'expiration + when@prod: framework: cache: @@ -11,3 +22,11 @@ when@prod: adapter: cache.adapter.system doctrine.result_cache_pool: adapter: cache.adapter.system + activation_tokens.cache: + adapter: cache.adapter.redis + provider: '%env(REDIS_URL)%' + default_lifetime: 604800 # 7 jours + users.cache: + adapter: cache.adapter.redis + provider: '%env(REDIS_URL)%' + default_lifetime: 0 # Pas d'expiration diff --git a/backend/config/packages/mailer.yaml b/backend/config/packages/mailer.yaml new file mode 100644 index 0000000..56a650d --- /dev/null +++ b/backend/config/packages/mailer.yaml @@ -0,0 +1,3 @@ +framework: + mailer: + dsn: '%env(MAILER_DSN)%' diff --git a/backend/config/packages/nelmio_cors.yaml b/backend/config/packages/nelmio_cors.yaml new file mode 100644 index 0000000..c766508 --- /dev/null +++ b/backend/config/packages/nelmio_cors.yaml @@ -0,0 +1,10 @@ +nelmio_cors: + defaults: + origin_regex: true + allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] + allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'] + allow_headers: ['Content-Type', 'Authorization'] + expose_headers: ['Link'] + max_age: 3600 + paths: + '^/': null diff --git a/backend/config/packages/security.yaml b/backend/config/packages/security.yaml index bd7a4a7..3c8ca64 100644 --- a/backend/config/packages/security.yaml +++ b/backend/config/packages/security.yaml @@ -2,6 +2,9 @@ security: # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords password_hashers: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' + # Named hasher for domain services (decoupled from User entity) + common: + algorithm: auto # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider providers: @@ -16,6 +19,10 @@ security: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false + api_public: + pattern: ^/api/(activation-tokens|activate|login|docs)(/|$) + stateless: true + security: false api: pattern: ^/api stateless: true @@ -29,6 +36,8 @@ security: access_control: - { path: ^/api/docs, roles: PUBLIC_ACCESS } - { path: ^/api/login, roles: PUBLIC_ACCESS } + - { path: ^/api/activation-tokens, roles: PUBLIC_ACCESS } + - { path: ^/api/activate, roles: PUBLIC_ACCESS } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } when@test: diff --git a/backend/config/services.yaml b/backend/config/services.yaml index 783d7f2..9476166 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -5,12 +5,21 @@ # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: tenant.base_domain: '%env(TENANT_BASE_DOMAIN)%' + app.url: '%env(APP_URL)%' services: # default configuration for services in this file _defaults: autowire: true # Automatically injects dependencies in your services. autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + bind: + # Bind activation tokens cache pool (7-day TTL) + Psr\Cache\CacheItemPoolInterface $activationTokensCache: '@activation_tokens.cache' + # Bind users cache pool (no TTL - persistent data) + Psr\Cache\CacheItemPoolInterface $usersCache: '@users.cache' + # Bind named message buses + Symfony\Component\Messenger\MessageBusInterface $eventBus: '@event.bus' + Symfony\Component\Messenger\MessageBusInterface $commandBus: '@command.bus' # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name @@ -43,3 +52,27 @@ services: App\Shared\Infrastructure\Tenant\Command\TenantMigrateCommand: arguments: $projectDir: '%kernel.project_dir%' + + # Administration services + # Bind Repository interfaces to their implementations + App\Administration\Domain\Repository\ActivationTokenRepository: + alias: App\Administration\Infrastructure\Persistence\Redis\RedisActivationTokenRepository + + App\Administration\Domain\Repository\UserRepository: + alias: App\Administration\Infrastructure\Persistence\Cache\CacheUserRepository + + App\Administration\Application\Port\PasswordHasher: + alias: App\Administration\Infrastructure\Security\SymfonyPasswordHasher + + # Clock interface binding + App\Shared\Domain\Clock: + alias: App\Shared\Infrastructure\Clock\SystemClock + + # Domain policies (need explicit registration as Domain is excluded from autowiring) + App\Administration\Domain\Policy\ConsentementParentalPolicy: + autowire: true + + # Email handlers + App\Administration\Infrastructure\Messaging\SendActivationConfirmationHandler: + arguments: + $appUrl: '%app.url%' diff --git a/backend/src/Administration/Application/Command/ActivateAccount/ActivateAccountCommand.php b/backend/src/Administration/Application/Command/ActivateAccount/ActivateAccountCommand.php new file mode 100644 index 0000000..3ac111e --- /dev/null +++ b/backend/src/Administration/Application/Command/ActivateAccount/ActivateAccountCommand.php @@ -0,0 +1,20 @@ +tokenRepository->findByTokenValue($command->tokenValue); + + if ($token === null) { + throw ActivationTokenNotFoundException::withTokenValue($command->tokenValue); + } + + $now = $this->clock->now(); + + // Validate token can be used (throws if expired or already used) + // Note: Token is NOT marked as used here - that's deferred to the processor + // after successful user activation, so failed activations don't burn the token + $token->validateForUse($now); + + // Hash the password for User model + $hashedPassword = $this->passwordHasher->hash($command->password); + + return new ActivateAccountResult( + userId: $token->userId, + email: $token->email, + tenantId: $token->tenantId, + role: $token->role, + hashedPassword: $hashedPassword, + ); + } +} diff --git a/backend/src/Administration/Application/Command/ActivateAccount/ActivateAccountResult.php b/backend/src/Administration/Application/Command/ActivateAccount/ActivateAccountResult.php new file mode 100644 index 0000000..92cee77 --- /dev/null +++ b/backend/src/Administration/Application/Command/ActivateAccount/ActivateAccountResult.php @@ -0,0 +1,25 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->tokenId->value; + } +} diff --git a/backend/src/Administration/Domain/Event/ActivationTokenUsed.php b/backend/src/Administration/Domain/Event/ActivationTokenUsed.php new file mode 100644 index 0000000..ccbc6d2 --- /dev/null +++ b/backend/src/Administration/Domain/Event/ActivationTokenUsed.php @@ -0,0 +1,33 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->tokenId->value; + } +} diff --git a/backend/src/Administration/Domain/Event/CompteActive.php b/backend/src/Administration/Domain/Event/CompteActive.php new file mode 100644 index 0000000..fe8949b --- /dev/null +++ b/backend/src/Administration/Domain/Event/CompteActive.php @@ -0,0 +1,42 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->aggregateId; + } +} diff --git a/backend/src/Administration/Domain/Event/CompteCreated.php b/backend/src/Administration/Domain/Event/CompteCreated.php new file mode 100644 index 0000000..2d82273 --- /dev/null +++ b/backend/src/Administration/Domain/Event/CompteCreated.php @@ -0,0 +1,39 @@ +occurredOn; + } + + #[Override] + public function aggregateId(): UuidInterface + { + return $this->userId->value; + } +} diff --git a/backend/src/Administration/Domain/Exception/ActivationTokenAlreadyUsedException.php b/backend/src/Administration/Domain/Exception/ActivationTokenAlreadyUsedException.php new file mode 100644 index 0000000..9d7025c --- /dev/null +++ b/backend/src/Administration/Domain/Exception/ActivationTokenAlreadyUsedException.php @@ -0,0 +1,21 @@ +value, + )); + } + + public static function carConsentementManquant(UserId $userId): self + { + return new self(sprintf( + 'Le compte "%s" ne peut pas être activé : consentement parental manquant.', + $userId, + )); + } +} diff --git a/backend/src/Administration/Domain/Exception/EmailInvalideException.php b/backend/src/Administration/Domain/Exception/EmailInvalideException.php new file mode 100644 index 0000000..280a75d --- /dev/null +++ b/backend/src/Administration/Domain/Exception/EmailInvalideException.php @@ -0,0 +1,20 @@ +toString(), + userId: $userId, + email: $email, + tenantId: $tenantId, + role: $role, + schoolName: $schoolName, + createdAt: $createdAt, + expiresAt: $createdAt->modify(sprintf('+%d days', self::EXPIRATION_DAYS)), + ); + + $token->recordEvent(new ActivationTokenGenerated( + tokenId: $token->id, + userId: $userId, + email: $email, + tenantId: $tenantId, + occurredOn: $createdAt, + )); + + return $token; + } + + /** + * Reconstitute an ActivationToken from storage. + * Does NOT record domain events (this is not a new creation). + * + * @internal For use by Infrastructure layer only + */ + public static function reconstitute( + ActivationTokenId $id, + string $tokenValue, + string $userId, + string $email, + TenantId $tenantId, + string $role, + string $schoolName, + DateTimeImmutable $createdAt, + DateTimeImmutable $expiresAt, + ?DateTimeImmutable $usedAt, + ): self { + $token = new self( + id: $id, + tokenValue: $tokenValue, + userId: $userId, + email: $email, + tenantId: $tenantId, + role: $role, + schoolName: $schoolName, + createdAt: $createdAt, + expiresAt: $expiresAt, + ); + + $token->usedAt = $usedAt; + + return $token; + } + + public function isExpired(DateTimeImmutable $at): bool + { + return $at >= $this->expiresAt; + } + + public function isUsed(): bool + { + return $this->usedAt !== null; + } + + /** + * Validate that the token can be used (not expired, not already used). + * Does NOT mark the token as used - use use() for that after successful activation. + * + * @throws ActivationTokenAlreadyUsedException if token was already used + * @throws ActivationTokenExpiredException if token is expired + */ + public function validateForUse(DateTimeImmutable $at): void + { + if ($this->isUsed()) { + throw ActivationTokenAlreadyUsedException::forToken($this->id); + } + + if ($this->isExpired($at)) { + throw ActivationTokenExpiredException::forToken($this->id); + } + } + + /** + * Mark the token as used. Should only be called after successful user activation. + * + * @throws ActivationTokenAlreadyUsedException if token was already used + * @throws ActivationTokenExpiredException if token is expired + */ + public function use(DateTimeImmutable $at): void + { + $this->validateForUse($at); + + $this->usedAt = $at; + + $this->recordEvent(new ActivationTokenUsed( + tokenId: $this->id, + userId: $this->userId, + occurredOn: $at, + )); + } +} diff --git a/backend/src/Administration/Domain/Model/ActivationToken/ActivationTokenId.php b/backend/src/Administration/Domain/Model/ActivationToken/ActivationTokenId.php new file mode 100644 index 0000000..3359f16 --- /dev/null +++ b/backend/src/Administration/Domain/Model/ActivationToken/ActivationTokenId.php @@ -0,0 +1,11 @@ +eleveId === $eleveId; + } +} diff --git a/backend/src/Administration/Domain/Model/User/Email.php b/backend/src/Administration/Domain/Model/User/Email.php new file mode 100644 index 0000000..9000bc4 --- /dev/null +++ b/backend/src/Administration/Domain/Model/User/Email.php @@ -0,0 +1,39 @@ +value = $value; + } + + public function equals(self $other): bool + { + return strtolower($this->value) === strtolower($other->value); + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/backend/src/Administration/Domain/Model/User/Role.php b/backend/src/Administration/Domain/Model/User/Role.php new file mode 100644 index 0000000..df3b810 --- /dev/null +++ b/backend/src/Administration/Domain/Model/User/Role.php @@ -0,0 +1,74 @@ +value => [ + self::ADMIN, self::PROF, self::VIE_SCOLAIRE, + self::SECRETARIAT, self::PARENT, self::ELEVE, + ], + self::ADMIN->value => [ + self::PROF, self::VIE_SCOLAIRE, self::SECRETARIAT, + ], + ]; + + $rolesInclus = $hierarchie[$this->value] ?? []; + + return in_array($autre, $rolesInclus, true); + } + + /** + * Retourne le libellé français du rôle. + */ + public function label(): string + { + return match ($this) { + self::SUPER_ADMIN => 'Super Administrateur', + self::ADMIN => 'Directeur', + self::PROF => 'Enseignant', + self::VIE_SCOLAIRE => 'Vie Scolaire', + self::SECRETARIAT => 'Secrétariat', + self::PARENT => 'Parent', + self::ELEVE => 'Élève', + }; + } + + /** + * Vérifie si ce rôle nécessite un consentement parental potentiel. + */ + public function peutEtreMineur(): bool + { + return $this === self::ELEVE; + } +} diff --git a/backend/src/Administration/Domain/Model/User/StatutCompte.php b/backend/src/Administration/Domain/Model/User/StatutCompte.php new file mode 100644 index 0000000..77d6930 --- /dev/null +++ b/backend/src/Administration/Domain/Model/User/StatutCompte.php @@ -0,0 +1,33 @@ +recordEvent(new CompteCreated( + userId: $user->id, + email: (string) $user->email, + role: $user->role->value, + tenantId: $user->tenantId, + occurredOn: $createdAt, + )); + + return $user; + } + + /** + * Active le compte avec le mot de passe hashé. + * + * @throws CompteNonActivableException si le compte ne peut pas être activé + */ + public function activer( + string $hashedPassword, + DateTimeImmutable $at, + ConsentementParentalPolicy $consentementPolicy, + ): void { + if (!$this->statut->peutActiver()) { + throw CompteNonActivableException::carStatutIncompatible($this->id, $this->statut); + } + + // Vérifier si le consentement parental est requis + if ($consentementPolicy->estRequis($this->dateNaissance)) { + if ($this->consentementParental === null) { + throw CompteNonActivableException::carConsentementManquant($this->id); + } + } + + $this->hashedPassword = $hashedPassword; + $this->statut = StatutCompte::ACTIF; + $this->activatedAt = $at; + + $this->recordEvent(new CompteActive( + userId: (string) $this->id, + email: (string) $this->email, + tenantId: $this->tenantId, + role: $this->role->value, + occurredOn: $at, + aggregateId: $this->id->value, + )); + } + + /** + * Enregistre le consentement parental donné par le parent. + */ + public function enregistrerConsentementParental(ConsentementParental $consentement): void + { + $this->consentementParental = $consentement; + + // Si le compte était en attente de consentement, passer en attente d'activation + if ($this->statut === StatutCompte::CONSENTEMENT_REQUIS) { + $this->statut = StatutCompte::EN_ATTENTE; + } + } + + /** + * Vérifie si cet utilisateur est mineur et nécessite un consentement parental. + */ + public function necessiteConsentementParental(ConsentementParentalPolicy $policy): bool + { + return $policy->estRequis($this->dateNaissance); + } + + /** + * Vérifie si le compte est actif et peut se connecter. + */ + public function peutSeConnecter(): bool + { + return $this->statut->peutSeConnecter(); + } + + /** + * Reconstitue un User depuis le stockage. + * + * @internal Pour usage par l'Infrastructure uniquement + */ + public static function reconstitute( + UserId $id, + Email $email, + Role $role, + TenantId $tenantId, + string $schoolName, + StatutCompte $statut, + ?DateTimeImmutable $dateNaissance, + DateTimeImmutable $createdAt, + ?string $hashedPassword, + ?DateTimeImmutable $activatedAt, + ?ConsentementParental $consentementParental, + ): self { + $user = new self( + id: $id, + email: $email, + role: $role, + tenantId: $tenantId, + schoolName: $schoolName, + statut: $statut, + dateNaissance: $dateNaissance, + createdAt: $createdAt, + ); + + $user->hashedPassword = $hashedPassword; + $user->activatedAt = $activatedAt; + $user->consentementParental = $consentementParental; + + return $user; + } +} diff --git a/backend/src/Administration/Domain/Model/User/UserId.php b/backend/src/Administration/Domain/Model/User/UserId.php new file mode 100644 index 0000000..bea4dba --- /dev/null +++ b/backend/src/Administration/Domain/Model/User/UserId.php @@ -0,0 +1,11 @@ +calculerAge($dateNaissance) < self::AGE_MAJORITE_NUMERIQUE; + } + + /** + * Calcule l'âge en années à partir de la date de naissance. + */ + private function calculerAge(DateTimeImmutable $dateNaissance): int + { + $now = $this->clock->now(); + $interval = $now->diff($dateNaissance); + + return $interval->y; + } +} diff --git a/backend/src/Administration/Domain/Repository/ActivationTokenRepository.php b/backend/src/Administration/Domain/Repository/ActivationTokenRepository.php new file mode 100644 index 0000000..e6f30d2 --- /dev/null +++ b/backend/src/Administration/Domain/Repository/ActivationTokenRepository.php @@ -0,0 +1,35 @@ + + */ +final readonly class ActivateAccountProcessor implements ProcessorInterface +{ + public function __construct( + private ActivateAccountHandler $handler, + private UserRepository $userRepository, + private ActivationTokenRepository $tokenRepository, + private ConsentementParentalPolicy $consentementPolicy, + private Clock $clock, + private MessageBusInterface $eventBus, + ) { + } + + /** + * @param ActivateAccountInput $data + */ + #[Override] + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ActivateAccountOutput + { + $command = new ActivateAccountCommand( + tokenValue: $data->tokenValue, + password: $data->password, + ); + + try { + $result = ($this->handler)($command); + } catch (ActivationTokenNotFoundException) { + throw new NotFoundHttpException('Token d\'activation invalide ou introuvable.'); + } catch (ActivationTokenExpiredException) { + throw new BadRequestHttpException('Le token d\'activation a expiré. Veuillez contacter votre établissement pour obtenir un nouveau lien.'); + } catch (ActivationTokenAlreadyUsedException) { + throw new BadRequestHttpException('Ce token d\'activation a déjà été utilisé.'); + } + + // Activate the User account + try { + $user = $this->userRepository->get(UserId::fromString($result->userId)); + $user->activer( + hashedPassword: $result->hashedPassword, + at: $this->clock->now(), + consentementPolicy: $this->consentementPolicy, + ); + $this->userRepository->save($user); + + // Publish domain events recorded on the User aggregate + foreach ($user->pullDomainEvents() as $event) { + $this->eventBus->dispatch($event); + } + + // Delete token only after successful user activation + // This ensures failed activations (e.g., missing parental consent) don't burn the token + $this->tokenRepository->deleteByTokenValue($data->tokenValue); + } catch (UserNotFoundException) { + throw new NotFoundHttpException('Utilisateur introuvable.'); + } catch (CompteNonActivableException $e) { + throw new BadRequestHttpException($e->getMessage()); + } + + return new ActivateAccountOutput( + userId: $result->userId, + email: $result->email, + role: $result->role, + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Provider/ActivationTokenInfoProvider.php b/backend/src/Administration/Infrastructure/Api/Provider/ActivationTokenInfoProvider.php new file mode 100644 index 0000000..e9af421 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Provider/ActivationTokenInfoProvider.php @@ -0,0 +1,62 @@ + + */ +final readonly class ActivationTokenInfoProvider implements ProviderInterface +{ + public function __construct( + private ActivationTokenRepository $tokenRepository, + private Clock $clock, + ) { + } + + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): ActivationTokenInfo + { + /** @var string $tokenValue */ + $tokenValue = $uriVariables['tokenValue'] ?? ''; + + $token = $this->tokenRepository->findByTokenValue($tokenValue); + + if ($token === null) { + throw new NotFoundHttpException('Token d\'activation introuvable.'); + } + + if ($token->isUsed()) { + throw new NotFoundHttpException('Ce token d\'activation a déjà été utilisé.'); + } + + return new ActivationTokenInfo( + tokenValue: $token->tokenValue, + email: $token->email, + role: $this->translateRole($token->role), + schoolName: $token->schoolName, + isExpired: $token->isExpired($this->clock->now()), + expiresAt: $token->expiresAt->format(DateTimeImmutable::ATOM), + ); + } + + private function translateRole(string $role): string + { + $roleEnum = Role::tryFrom($role); + + return $roleEnum?->label() ?? $role; + } +} diff --git a/backend/src/Administration/Infrastructure/Api/Resource/ActivateAccountInput.php b/backend/src/Administration/Infrastructure/Api/Resource/ActivateAccountInput.php new file mode 100644 index 0000000..e519a15 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Resource/ActivateAccountInput.php @@ -0,0 +1,49 @@ + ['Default', 'activate']], + name: 'activate_account', + ), + ], +)] +final class ActivateAccountInput +{ + #[Assert\NotBlank(message: 'Le token d\'activation est requis.')] + #[Assert\Uuid(message: 'Le token d\'activation doit être un UUID valide.')] + public string $tokenValue = ''; + + #[Assert\NotBlank(message: 'Le mot de passe est requis.')] + #[Assert\Length( + min: 8, + minMessage: 'Le mot de passe doit contenir au moins {{ limit }} caractères.', + )] + #[Assert\Regex( + pattern: '/[A-Z]/', + message: 'Le mot de passe doit contenir au moins une majuscule.', + )] + #[Assert\Regex( + pattern: '/[0-9]/', + message: 'Le mot de passe doit contenir au moins un chiffre.', + )] + public string $password = ''; +} diff --git a/backend/src/Administration/Infrastructure/Api/Resource/ActivateAccountOutput.php b/backend/src/Administration/Infrastructure/Api/Resource/ActivateAccountOutput.php new file mode 100644 index 0000000..ef42aac --- /dev/null +++ b/backend/src/Administration/Infrastructure/Api/Resource/ActivateAccountOutput.php @@ -0,0 +1,19 @@ +addOption('email', null, InputOption::VALUE_OPTIONAL, 'Email address', 'test@example.com') + ->addOption('role', null, InputOption::VALUE_OPTIONAL, 'User role (PARENT, ELEVE, PROF, ADMIN)', 'PARENT') + ->addOption('school', null, InputOption::VALUE_OPTIONAL, 'School name', 'École de Test') + ->addOption('minor', null, InputOption::VALUE_NONE, 'Create a minor user (requires parental consent)') + ->addOption('base-url', null, InputOption::VALUE_OPTIONAL, 'Frontend base URL', 'http://localhost:5173'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + /** @var string $email */ + $email = $input->getOption('email'); + /** @var string $roleOption */ + $roleOption = $input->getOption('role'); + $roleInput = strtoupper($roleOption); + /** @var string $schoolName */ + $schoolName = $input->getOption('school'); + $isMinor = $input->getOption('minor'); + /** @var string $baseUrlOption */ + $baseUrlOption = $input->getOption('base-url'); + $baseUrl = rtrim($baseUrlOption, '/'); + + // Convert short role name to full Symfony role format + $roleName = str_starts_with($roleInput, 'ROLE_') ? $roleInput : 'ROLE_' . $roleInput; + + $role = Role::tryFrom($roleName); + if ($role === null) { + $validRoles = array_map(static fn (Role $r) => str_replace('ROLE_', '', $r->value), Role::cases()); + $io->error(sprintf( + 'Invalid role "%s". Valid roles: %s', + $roleInput, + implode(', ', $validRoles) + )); + + return Command::FAILURE; + } + + $now = $this->clock->now(); + $tenantId = TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'); + + // Create user + $dateNaissance = $isMinor + ? $now->modify('-13 years') // 13 ans = mineur + : null; + + $user = User::creer( + email: new Email($email), + role: $role, + tenantId: $tenantId, + schoolName: $schoolName, + dateNaissance: $dateNaissance, + createdAt: $now, + ); + + $this->userRepository->save($user); + + // Create activation token + $token = ActivationToken::generate( + userId: (string) $user->id, + email: $email, + tenantId: $tenantId, + role: $role->value, + schoolName: $schoolName, + createdAt: $now, + ); + + $this->activationTokenRepository->save($token); + + $activationUrl = sprintf('%s/activate/%s', $baseUrl, $token->tokenValue); + + $io->success('Test activation token created successfully!'); + + $io->table( + ['Property', 'Value'], + [ + ['User ID', (string) $user->id], + ['Email', $email], + ['Role', $role->value], + ['School', $schoolName], + ['Minor', $isMinor ? 'Yes (requires parental consent)' : 'No'], + ['Token', $token->tokenValue], + ['Expires', $token->expiresAt->format('Y-m-d H:i:s')], + ] + ); + + $io->writeln(''); + $io->writeln(sprintf('Activation URL: %s', $activationUrl, $activationUrl)); + $io->writeln(''); + + return Command::SUCCESS; + } +} diff --git a/backend/src/Administration/Infrastructure/Messaging/SendActivationConfirmationHandler.php b/backend/src/Administration/Infrastructure/Messaging/SendActivationConfirmationHandler.php new file mode 100644 index 0000000..2ef4c62 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Messaging/SendActivationConfirmationHandler.php @@ -0,0 +1,50 @@ +role); + $roleLabel = $roleEnum?->label() ?? $event->role; + + $html = $this->twig->render('emails/activation_confirmation.html.twig', [ + 'email' => $event->email, + 'role' => $roleLabel, + 'loginUrl' => rtrim($this->appUrl, '/') . '/login', + ]); + + $email = (new Email()) + ->from($this->fromEmail) + ->to($event->email) + ->subject('Votre compte Classeo est activé') + ->html($html); + + $this->mailer->send($email); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/Cache/CacheUserRepository.php b/backend/src/Administration/Infrastructure/Persistence/Cache/CacheUserRepository.php new file mode 100644 index 0000000..b9fd0c1 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/Cache/CacheUserRepository.php @@ -0,0 +1,162 @@ +usersCache->getItem(self::KEY_PREFIX . $user->id); + $item->set($this->serialize($user)); + $this->usersCache->save($item); + + // Save email index for lookup + $emailItem = $this->usersCache->getItem(self::EMAIL_INDEX_PREFIX . $this->normalizeEmail($user->email)); + $emailItem->set((string) $user->id); + $this->usersCache->save($emailItem); + } + + public function findById(UserId $id): ?User + { + $item = $this->usersCache->getItem(self::KEY_PREFIX . $id); + + if (!$item->isHit()) { + return null; + } + + /** @var array{id: string, email: string, role: string, tenant_id: string, school_name: string, statut: string, hashed_password: string|null, date_naissance: string|null, created_at: string, activated_at: string|null, consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null} $data */ + $data = $item->get(); + + return $this->deserialize($data); + } + + public function findByEmail(Email $email): ?User + { + $emailItem = $this->usersCache->getItem(self::EMAIL_INDEX_PREFIX . $this->normalizeEmail($email)); + + if (!$emailItem->isHit()) { + return null; + } + + /** @var string $userId */ + $userId = $emailItem->get(); + + return $this->findById(UserId::fromString($userId)); + } + + public function get(UserId $id): User + { + $user = $this->findById($id); + + if ($user === null) { + throw UserNotFoundException::withId($id); + } + + return $user; + } + + /** + * @return array + */ + private function serialize(User $user): array + { + $consentement = $user->consentementParental; + + return [ + 'id' => (string) $user->id, + 'email' => (string) $user->email, + 'role' => $user->role->value, + 'tenant_id' => (string) $user->tenantId, + 'school_name' => $user->schoolName, + 'statut' => $user->statut->value, + 'hashed_password' => $user->hashedPassword, + 'date_naissance' => $user->dateNaissance?->format('Y-m-d'), + 'created_at' => $user->createdAt->format('c'), + 'activated_at' => $user->activatedAt?->format('c'), + 'consentement_parental' => $consentement !== null ? [ + 'parent_id' => $consentement->parentId, + 'eleve_id' => $consentement->eleveId, + 'date_consentement' => $consentement->dateConsentement->format('c'), + 'ip_address' => $consentement->ipAddress, + ] : null, + ]; + } + + /** + * @param array{ + * id: string, + * email: string, + * role: string, + * tenant_id: string, + * school_name: string, + * statut: string, + * hashed_password: string|null, + * date_naissance: string|null, + * created_at: string, + * activated_at: string|null, + * consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null + * } $data + */ + private function deserialize(array $data): User + { + $consentement = null; + if ($data['consentement_parental'] !== null) { + $consentementData = $data['consentement_parental']; + $consentement = ConsentementParental::accorder( + parentId: $consentementData['parent_id'], + eleveId: $consentementData['eleve_id'], + at: new DateTimeImmutable($consentementData['date_consentement']), + ipAddress: $consentementData['ip_address'], + ); + } + + return User::reconstitute( + id: UserId::fromString($data['id']), + email: new Email($data['email']), + role: Role::from($data['role']), + tenantId: TenantId::fromString($data['tenant_id']), + schoolName: $data['school_name'], + statut: StatutCompte::from($data['statut']), + dateNaissance: $data['date_naissance'] !== null ? new DateTimeImmutable($data['date_naissance']) : null, + createdAt: new DateTimeImmutable($data['created_at']), + hashedPassword: $data['hashed_password'], + activatedAt: $data['activated_at'] !== null ? new DateTimeImmutable($data['activated_at']) : null, + consentementParental: $consentement, + ); + } + + private function normalizeEmail(Email $email): string + { + return strtolower(str_replace(['@', '.'], ['_at_', '_dot_'], (string) $email)); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryActivationTokenRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryActivationTokenRepository.php new file mode 100644 index 0000000..79dba64 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryActivationTokenRepository.php @@ -0,0 +1,75 @@ + Indexed by token value */ + private array $byTokenValue = []; + + /** @var array Maps ID to token value */ + private array $idToTokenValue = []; + + #[Override] + public function save(ActivationToken $token): void + { + $this->byTokenValue[$token->tokenValue] = $token; + $this->idToTokenValue[(string) $token->id] = $token->tokenValue; + } + + #[Override] + public function findByTokenValue(string $tokenValue): ?ActivationToken + { + return $this->byTokenValue[$tokenValue] ?? null; + } + + #[Override] + public function get(ActivationTokenId $id): ActivationToken + { + $tokenValue = $this->idToTokenValue[(string) $id] ?? null; + + if ($tokenValue === null) { + throw ActivationTokenNotFoundException::withId($id); + } + + $token = $this->byTokenValue[$tokenValue] ?? null; + + if ($token === null) { + throw ActivationTokenNotFoundException::withId($id); + } + + return $token; + } + + #[Override] + public function delete(ActivationTokenId $id): void + { + $tokenValue = $this->idToTokenValue[(string) $id] ?? null; + + if ($tokenValue !== null) { + unset($this->byTokenValue[$tokenValue]); + } + + unset($this->idToTokenValue[(string) $id]); + } + + #[Override] + public function deleteByTokenValue(string $tokenValue): void + { + $token = $this->byTokenValue[$tokenValue] ?? null; + + if ($token !== null) { + unset($this->idToTokenValue[(string) $token->id]); + } + + unset($this->byTokenValue[$tokenValue]); + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryUserRepository.php b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryUserRepository.php new file mode 100644 index 0000000..c22c5e2 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/InMemory/InMemoryUserRepository.php @@ -0,0 +1,46 @@ + Indexed by ID */ + private array $byId = []; + + /** @var array Indexed by email (lowercase) */ + private array $byEmail = []; + + #[Override] + public function save(User $user): void + { + $this->byId[(string) $user->id] = $user; + $this->byEmail[strtolower((string) $user->email)] = $user; + } + + #[Override] + public function get(UserId $id): User + { + $user = $this->byId[(string) $id] ?? null; + + if ($user === null) { + throw UserNotFoundException::withId($id); + } + + return $user; + } + + #[Override] + public function findByEmail(Email $email): ?User + { + return $this->byEmail[strtolower((string) $email)] ?? null; + } +} diff --git a/backend/src/Administration/Infrastructure/Persistence/Redis/RedisActivationTokenRepository.php b/backend/src/Administration/Infrastructure/Persistence/Redis/RedisActivationTokenRepository.php new file mode 100644 index 0000000..16648dd --- /dev/null +++ b/backend/src/Administration/Infrastructure/Persistence/Redis/RedisActivationTokenRepository.php @@ -0,0 +1,142 @@ +activationTokensCache->getItem(self::KEY_PREFIX . $token->tokenValue); + $item->set($this->serialize($token)); + $item->expiresAfter(self::TTL_SECONDS); + $this->activationTokensCache->save($item); + + // Also store by ID for direct access + $idItem = $this->activationTokensCache->getItem(self::KEY_PREFIX . 'id:' . $token->id); + $idItem->set($token->tokenValue); + $idItem->expiresAfter(self::TTL_SECONDS); + $this->activationTokensCache->save($idItem); + } + + #[Override] + public function findByTokenValue(string $tokenValue): ?ActivationToken + { + $item = $this->activationTokensCache->getItem(self::KEY_PREFIX . $tokenValue); + + if (!$item->isHit()) { + return null; + } + + /** @var array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null} $data */ + $data = $item->get(); + + return $this->deserialize($data); + } + + #[Override] + public function get(ActivationTokenId $id): ActivationToken + { + // First get the token value from the ID index + $idItem = $this->activationTokensCache->getItem(self::KEY_PREFIX . 'id:' . $id); + + if (!$idItem->isHit()) { + throw ActivationTokenNotFoundException::withId($id); + } + + /** @var string $tokenValue */ + $tokenValue = $idItem->get(); + $token = $this->findByTokenValue($tokenValue); + + if ($token === null) { + throw ActivationTokenNotFoundException::withId($id); + } + + return $token; + } + + #[Override] + public function delete(ActivationTokenId $id): void + { + // Get token value first + $idItem = $this->activationTokensCache->getItem(self::KEY_PREFIX . 'id:' . $id); + + if ($idItem->isHit()) { + /** @var string $tokenValue */ + $tokenValue = $idItem->get(); + $this->activationTokensCache->deleteItem(self::KEY_PREFIX . $tokenValue); + } + + $this->activationTokensCache->deleteItem(self::KEY_PREFIX . 'id:' . $id); + } + + #[Override] + public function deleteByTokenValue(string $tokenValue): void + { + $token = $this->findByTokenValue($tokenValue); + + if ($token !== null) { + $this->activationTokensCache->deleteItem(self::KEY_PREFIX . 'id:' . $token->id); + } + + $this->activationTokensCache->deleteItem(self::KEY_PREFIX . $tokenValue); + } + + /** + * @return array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null} + */ + private function serialize(ActivationToken $token): array + { + return [ + 'id' => (string) $token->id, + 'token_value' => $token->tokenValue, + 'user_id' => $token->userId, + 'email' => $token->email, + 'tenant_id' => (string) $token->tenantId, + 'role' => $token->role, + 'school_name' => $token->schoolName, + 'created_at' => $token->createdAt->format(DateTimeImmutable::ATOM), + 'expires_at' => $token->expiresAt->format(DateTimeImmutable::ATOM), + 'used_at' => $token->usedAt?->format(DateTimeImmutable::ATOM), + ]; + } + + /** + * @param array{id: string, token_value: string, user_id: string, email: string, tenant_id: string, role: string, school_name: string, created_at: string, expires_at: string, used_at: string|null} $data + */ + private function deserialize(array $data): ActivationToken + { + return ActivationToken::reconstitute( + id: ActivationTokenId::fromString($data['id']), + tokenValue: $data['token_value'], + userId: $data['user_id'], + email: $data['email'], + tenantId: TenantId::fromString($data['tenant_id']), + role: $data['role'], + schoolName: $data['school_name'], + createdAt: new DateTimeImmutable($data['created_at']), + expiresAt: new DateTimeImmutable($data['expires_at']), + usedAt: $data['used_at'] !== null ? new DateTimeImmutable($data['used_at']) : null, + ); + } +} diff --git a/backend/src/Administration/Infrastructure/Security/SymfonyPasswordHasher.php b/backend/src/Administration/Infrastructure/Security/SymfonyPasswordHasher.php new file mode 100644 index 0000000..ef48a61 --- /dev/null +++ b/backend/src/Administration/Infrastructure/Security/SymfonyPasswordHasher.php @@ -0,0 +1,40 @@ +hasherFactory + ->getPasswordHasher(self::HASHER_ID) + ->hash($plainPassword); + } + + #[Override] + public function verify(string $hashedPassword, string $plainPassword): bool + { + return $this->hasherFactory + ->getPasswordHasher(self::HASHER_ID) + ->verify($hashedPassword, $plainPassword); + } +} diff --git a/backend/src/Shared/Infrastructure/Tenant/TenantMiddleware.php b/backend/src/Shared/Infrastructure/Tenant/TenantMiddleware.php index 567e490..dc1fd59 100644 --- a/backend/src/Shared/Infrastructure/Tenant/TenantMiddleware.php +++ b/backend/src/Shared/Infrastructure/Tenant/TenantMiddleware.php @@ -31,6 +31,9 @@ final readonly class TenantMiddleware implements EventSubscriberInterface '/api/docs.json', '/api/docs.jsonld', '/api/contexts', + '/api/activation-tokens', + '/api/activate', + '/api/login', '/_profiler', '/_wdt', '/_error', diff --git a/backend/symfony.lock b/backend/symfony.lock index dbc51ba..ca11a38 100644 --- a/backend/symfony.lock +++ b/backend/symfony.lock @@ -85,6 +85,18 @@ "config/packages/lexik_jwt_authentication.yaml" ] }, + "nelmio/cors-bundle": { + "version": "2.6", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.5", + "ref": "6bea22e6c564fba3a1391615cada1437d0bde39c" + }, + "files": [ + "config/packages/nelmio_cors.yaml" + ] + }, "phpstan/phpstan": { "version": "2.1", "recipe": { @@ -166,6 +178,18 @@ ".editorconfig" ] }, + "symfony/mailer": { + "version": "8.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "4.3", + "ref": "09051cfde49476e3c12cd3a0e44289ace1c75a4f" + }, + "files": [ + "config/packages/mailer.yaml" + ] + }, "symfony/maker-bundle": { "version": "1.65", "recipe": { diff --git a/backend/templates/emails/activation_confirmation.html.twig b/backend/templates/emails/activation_confirmation.html.twig new file mode 100644 index 0000000..b18ea56 --- /dev/null +++ b/backend/templates/emails/activation_confirmation.html.twig @@ -0,0 +1,113 @@ + + + + + + Compte activé - Classeo + + + +
+

Classeo

+
+ +
+
+ +
+ +

Votre compte est activé !

+ +

Bonjour,

+ +

Nous vous confirmons que votre compte Classeo a été activé avec succès.

+ +
+

Email : {{ email }}

+

Rôle : {{ role }}

+
+ +

Vous pouvez maintenant vous connecter à Classeo pour accéder à toutes les fonctionnalités disponibles.

+ +

+ Se connecter +

+ +

Conseils de sécurité :

+
    +
  • Ne partagez jamais votre mot de passe
  • +
  • Déconnectez-vous après utilisation sur un ordinateur partagé
  • +
  • Contactez votre établissement en cas de problème d'accès
  • +
+
+ + + + diff --git a/backend/tests/Unit/Administration/Application/Command/ActivateAccount/ActivateAccountHandlerTest.php b/backend/tests/Unit/Administration/Application/Command/ActivateAccount/ActivateAccountHandlerTest.php new file mode 100644 index 0000000..83cb50d --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Command/ActivateAccount/ActivateAccountHandlerTest.php @@ -0,0 +1,182 @@ +tokenRepository = new InMemoryActivationTokenRepository(); + $this->passwordHasher = new class implements PasswordHasher { + #[Override] + public function hash(string $plainPassword): string + { + return '$argon2id$hashed_password'; + } + + #[Override] + public function verify(string $hashedPassword, string $plainPassword): bool + { + return true; + } + }; + $this->clock = new class implements Clock { + public DateTimeImmutable $now; + + public function __construct() + { + $this->now = new DateTimeImmutable('2026-01-16 10:00:00'); + } + + #[Override] + public function now(): DateTimeImmutable + { + return $this->now; + } + }; + + $this->handler = new ActivateAccountHandler( + $this->tokenRepository, + $this->passwordHasher, + $this->clock, + ); + } + + #[Test] + public function activateAccountSuccessfully(): void + { + $token = $this->createAndSaveToken(); + + $command = new ActivateAccountCommand( + tokenValue: $token->tokenValue, + password: self::PASSWORD, + ); + + $result = ($this->handler)($command); + + self::assertInstanceOf(ActivateAccountResult::class, $result); + self::assertSame(self::USER_ID, $result->userId); + self::assertSame(self::EMAIL, $result->email); + self::assertSame(self::ROLE, $result->role); + self::assertSame(self::HASHED_PASSWORD, $result->hashedPassword); + } + + #[Test] + public function activateAccountValidatesButDoesNotConsumeToken(): void + { + // Handler only validates the token - consumption is deferred to the processor + // after successful user activation, so failed activations don't burn the token + $token = $this->createAndSaveToken(); + $tokenValue = $token->tokenValue; + + $command = new ActivateAccountCommand( + tokenValue: $tokenValue, + password: self::PASSWORD, + ); + + ($this->handler)($command); + + // Token should still exist and NOT be marked as used + $updatedToken = $this->tokenRepository->findByTokenValue($tokenValue); + self::assertNotNull($updatedToken); + self::assertFalse($updatedToken->isUsed()); + } + + #[Test] + public function activateAccountThrowsWhenTokenNotFound(): void + { + $command = new ActivateAccountCommand( + tokenValue: 'non-existent-token', + password: self::PASSWORD, + ); + + $this->expectException(ActivationTokenNotFoundException::class); + + ($this->handler)($command); + } + + #[Test] + public function activateAccountThrowsWhenTokenExpired(): void + { + $token = $this->createAndSaveToken( + createdAt: new DateTimeImmutable('2026-01-01 10:00:00'), + ); + + // Clock is set to 2026-01-16, token expires 2026-01-08 + $command = new ActivateAccountCommand( + tokenValue: $token->tokenValue, + password: self::PASSWORD, + ); + + $this->expectException(ActivationTokenExpiredException::class); + + ($this->handler)($command); + } + + #[Test] + public function activateAccountThrowsWhenTokenAlreadyUsed(): void + { + $token = $this->createAndSaveToken(); + + // Simulate a token that was already used (e.g., by the processor after successful activation) + $token->use($this->clock->now()); + $this->tokenRepository->save($token); + + $command = new ActivateAccountCommand( + tokenValue: $token->tokenValue, + password: self::PASSWORD, + ); + + // Should fail because token is already used + $this->expectException(ActivationTokenAlreadyUsedException::class); + + ($this->handler)($command); + } + + private function createAndSaveToken(?DateTimeImmutable $createdAt = null): ActivationToken + { + $token = ActivationToken::generate( + userId: self::USER_ID, + email: self::EMAIL, + tenantId: TenantId::fromString(self::TENANT_ID), + role: self::ROLE, + schoolName: self::SCHOOL_NAME, + createdAt: $createdAt ?? new DateTimeImmutable('2026-01-15 10:00:00'), + ); + + $this->tokenRepository->save($token); + + return $token; + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/ActivationToken/ActivationTokenIdTest.php b/backend/tests/Unit/Administration/Domain/Model/ActivationToken/ActivationTokenIdTest.php new file mode 100644 index 0000000..936113d --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/ActivationToken/ActivationTokenIdTest.php @@ -0,0 +1,50 @@ +equals($id2)); + } + + #[Test] + public function equalsReturnsFalseForDifferentValue(): void + { + $id1 = ActivationTokenId::generate(); + $id2 = ActivationTokenId::generate(); + + self::assertFalse($id1->equals($id2)); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/ActivationToken/ActivationTokenTest.php b/backend/tests/Unit/Administration/Domain/Model/ActivationToken/ActivationTokenTest.php new file mode 100644 index 0000000..7ec9594 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/ActivationToken/ActivationTokenTest.php @@ -0,0 +1,219 @@ +id); + self::assertSame($userId, $token->userId); + self::assertSame($email, $token->email); + self::assertTrue($tenantId->equals($token->tenantId)); + self::assertSame($role, $token->role); + self::assertSame($schoolName, $token->schoolName); + self::assertEquals($now, $token->createdAt); + self::assertFalse($token->isUsed()); + } + + #[Test] + public function generateRecordsActivationTokenGeneratedEvent(): void + { + $token = $this->createToken(); + + $events = $token->pullDomainEvents(); + + self::assertCount(1, $events); + self::assertInstanceOf(ActivationTokenGenerated::class, $events[0]); + } + + #[Test] + public function tokenValueIsUuidV4Format(): void + { + $token = $this->createToken(); + + self::assertMatchesRegularExpression( + '/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i', + $token->tokenValue, + ); + } + + #[Test] + public function expiresAtIs7DaysAfterCreation(): void + { + $createdAt = new DateTimeImmutable('2026-01-15 10:00:00'); + $expectedExpiration = new DateTimeImmutable('2026-01-22 10:00:00'); + + $token = ActivationToken::generate( + userId: self::USER_ID, + email: self::EMAIL, + tenantId: TenantId::fromString(self::TENANT_ID), + role: self::ROLE, + schoolName: self::SCHOOL_NAME, + createdAt: $createdAt, + ); + + self::assertEquals($expectedExpiration, $token->expiresAt); + } + + #[Test] + public function isExpiredReturnsFalseWhenNotExpired(): void + { + $createdAt = new DateTimeImmutable('2026-01-15 10:00:00'); + $checkAt = new DateTimeImmutable('2026-01-20 10:00:00'); + + $token = ActivationToken::generate( + userId: self::USER_ID, + email: self::EMAIL, + tenantId: TenantId::fromString(self::TENANT_ID), + role: self::ROLE, + schoolName: self::SCHOOL_NAME, + createdAt: $createdAt, + ); + + self::assertFalse($token->isExpired($checkAt)); + } + + #[Test] + public function isExpiredReturnsTrueWhenExpired(): void + { + $createdAt = new DateTimeImmutable('2026-01-15 10:00:00'); + $checkAt = new DateTimeImmutable('2026-01-25 10:00:00'); + + $token = ActivationToken::generate( + userId: self::USER_ID, + email: self::EMAIL, + tenantId: TenantId::fromString(self::TENANT_ID), + role: self::ROLE, + schoolName: self::SCHOOL_NAME, + createdAt: $createdAt, + ); + + self::assertTrue($token->isExpired($checkAt)); + } + + #[Test] + public function isExpiredReturnsTrueAtExactExpirationMoment(): void + { + $createdAt = new DateTimeImmutable('2026-01-15 10:00:00'); + $checkAt = new DateTimeImmutable('2026-01-22 10:00:00'); + + $token = ActivationToken::generate( + userId: self::USER_ID, + email: self::EMAIL, + tenantId: TenantId::fromString(self::TENANT_ID), + role: self::ROLE, + schoolName: self::SCHOOL_NAME, + createdAt: $createdAt, + ); + + self::assertTrue($token->isExpired($checkAt)); + } + + #[Test] + public function useMarksTokenAsUsed(): void + { + $token = $this->createToken(); + $usedAt = new DateTimeImmutable('2026-01-16 10:00:00'); + + $token->use($usedAt); + + self::assertTrue($token->isUsed()); + self::assertEquals($usedAt, $token->usedAt); + } + + #[Test] + public function useRecordsActivationTokenUsedEvent(): void + { + $token = $this->createToken(); + $token->pullDomainEvents(); + + $usedAt = new DateTimeImmutable('2026-01-16 10:00:00'); + $token->use($usedAt); + + $events = $token->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(ActivationTokenUsed::class, $events[0]); + } + + #[Test] + public function useThrowsExceptionWhenTokenAlreadyUsed(): void + { + $token = $this->createToken(); + $firstUse = new DateTimeImmutable('2026-01-16 10:00:00'); + $token->use($firstUse); + + $this->expectException(ActivationTokenAlreadyUsedException::class); + + $secondUse = new DateTimeImmutable('2026-01-17 10:00:00'); + $token->use($secondUse); + } + + #[Test] + public function useThrowsExceptionWhenTokenExpired(): void + { + $createdAt = new DateTimeImmutable('2026-01-15 10:00:00'); + $usedAt = new DateTimeImmutable('2026-01-25 10:00:00'); + + $token = ActivationToken::generate( + userId: self::USER_ID, + email: self::EMAIL, + tenantId: TenantId::fromString(self::TENANT_ID), + role: self::ROLE, + schoolName: self::SCHOOL_NAME, + createdAt: $createdAt, + ); + + $this->expectException(ActivationTokenExpiredException::class); + + $token->use($usedAt); + } + + private function createToken(): ActivationToken + { + return ActivationToken::generate( + userId: self::USER_ID, + email: self::EMAIL, + tenantId: TenantId::fromString(self::TENANT_ID), + role: self::ROLE, + schoolName: self::SCHOOL_NAME, + createdAt: new DateTimeImmutable('2026-01-15 10:00:00'), + ); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Model/User/UserTest.php b/backend/tests/Unit/Administration/Domain/Model/User/UserTest.php new file mode 100644 index 0000000..f8ad842 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Model/User/UserTest.php @@ -0,0 +1,220 @@ +clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-01-31 10:00:00'); + } + }; + + $this->consentementPolicy = new ConsentementParentalPolicy($this->clock); + } + + #[Test] + public function creerCreatesUserWithPendingStatus(): void + { + $user = $this->createUser(); + + self::assertSame(StatutCompte::EN_ATTENTE, $user->statut); + self::assertNull($user->hashedPassword); + self::assertNull($user->activatedAt); + } + + #[Test] + public function creerRecordsCompteCreatedEvent(): void + { + $user = $this->createUser(); + + $events = $user->pullDomainEvents(); + + self::assertCount(1, $events); + self::assertInstanceOf(CompteCreated::class, $events[0]); + } + + #[Test] + public function activerSetsPasswordAndChangesStatusToActive(): void + { + $user = $this->createUser(); + $hashedPassword = '$argon2id$hashed'; + $activatedAt = new DateTimeImmutable('2026-01-31 10:00:00'); + + $user->activer($hashedPassword, $activatedAt, $this->consentementPolicy); + + self::assertSame(StatutCompte::ACTIF, $user->statut); + self::assertSame($hashedPassword, $user->hashedPassword); + self::assertEquals($activatedAt, $user->activatedAt); + } + + #[Test] + public function activerRecordsCompteActiveEvent(): void + { + $user = $this->createUser(); + $user->pullDomainEvents(); + + $user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy); + + $events = $user->pullDomainEvents(); + self::assertCount(1, $events); + self::assertInstanceOf(CompteActive::class, $events[0]); + } + + #[Test] + public function activerThrowsWhenStatusIsNotPending(): void + { + $user = $this->createUser(); + $user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy); + + $this->expectException(CompteNonActivableException::class); + + $user->activer('$argon2id$another', new DateTimeImmutable(), $this->consentementPolicy); + } + + #[Test] + public function activerThrowsForMinorWithoutConsent(): void + { + // Créer un utilisateur mineur (14 ans) + $user = User::creer( + email: new Email('eleve@example.com'), + role: Role::ELEVE, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: self::SCHOOL_NAME, + dateNaissance: new DateTimeImmutable('2012-06-15'), // 13 ans + createdAt: new DateTimeImmutable('2026-01-15 10:00:00'), + ); + + $this->expectException(CompteNonActivableException::class); + $this->expectExceptionMessage('consentement parental manquant'); + + $user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy); + } + + #[Test] + public function activerSucceedsForMinorWithConsent(): void + { + // Créer un utilisateur mineur (14 ans) + $user = User::creer( + email: new Email('eleve@example.com'), + role: Role::ELEVE, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: self::SCHOOL_NAME, + dateNaissance: new DateTimeImmutable('2012-06-15'), + createdAt: new DateTimeImmutable('2026-01-15 10:00:00'), + ); + + // Enregistrer le consentement parental + $consentement = ConsentementParental::accorder( + parentId: 'parent-uuid', + eleveId: (string) $user->id, + at: new DateTimeImmutable('2026-01-20 10:00:00'), + ipAddress: '192.168.1.1', + ); + $user->enregistrerConsentementParental($consentement); + + // L'activation devrait maintenant réussir + $user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy); + + self::assertSame(StatutCompte::ACTIF, $user->statut); + } + + #[Test] + public function activerSucceedsForAdultWithoutConsent(): void + { + // Créer un utilisateur adulte (16 ans) + $user = User::creer( + email: new Email('eleve@example.com'), + role: Role::ELEVE, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: self::SCHOOL_NAME, + dateNaissance: new DateTimeImmutable('2010-01-01'), // 16 ans + createdAt: new DateTimeImmutable('2026-01-15 10:00:00'), + ); + + // Pas de consentement nécessaire + $user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy); + + self::assertSame(StatutCompte::ACTIF, $user->statut); + } + + #[Test] + public function peutSeConnecterReturnsTrueOnlyWhenActive(): void + { + $user = $this->createUser(); + + self::assertFalse($user->peutSeConnecter()); + + $user->activer('$argon2id$hashed', new DateTimeImmutable(), $this->consentementPolicy); + + self::assertTrue($user->peutSeConnecter()); + } + + #[Test] + public function necessiteConsentementParentalReturnsTrueForMinor(): void + { + $user = User::creer( + email: new Email('eleve@example.com'), + role: Role::ELEVE, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: self::SCHOOL_NAME, + dateNaissance: new DateTimeImmutable('2012-06-15'), // 13 ans + createdAt: new DateTimeImmutable('2026-01-15 10:00:00'), + ); + + self::assertTrue($user->necessiteConsentementParental($this->consentementPolicy)); + } + + #[Test] + public function necessiteConsentementParentalReturnsFalseForAdult(): void + { + $user = User::creer( + email: new Email('parent@example.com'), + role: Role::PARENT, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: self::SCHOOL_NAME, + dateNaissance: null, // Parents n'ont pas de date de naissance enregistrée + createdAt: new DateTimeImmutable('2026-01-15 10:00:00'), + ); + + self::assertFalse($user->necessiteConsentementParental($this->consentementPolicy)); + } + + private function createUser(): User + { + return User::creer( + email: new Email('user@example.com'), + role: Role::PARENT, + tenantId: TenantId::fromString(self::TENANT_ID), + schoolName: self::SCHOOL_NAME, + dateNaissance: null, + createdAt: new DateTimeImmutable('2026-01-15 10:00:00'), + ); + } +} diff --git a/backend/tests/Unit/Administration/Domain/Policy/ConsentementParentalPolicyTest.php b/backend/tests/Unit/Administration/Domain/Policy/ConsentementParentalPolicyTest.php new file mode 100644 index 0000000..2744d08 --- /dev/null +++ b/backend/tests/Unit/Administration/Domain/Policy/ConsentementParentalPolicyTest.php @@ -0,0 +1,105 @@ +clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-01-31 10:00:00'); + } + }; + + $this->policy = new ConsentementParentalPolicy($this->clock); + } + + #[Test] + public function consentementRequisPourUtilisateurDe14Ans(): void + { + $dateNaissance = new DateTimeImmutable('2012-01-31'); + + self::assertTrue($this->policy->estRequis($dateNaissance)); + } + + #[Test] + public function consentementRequisPourUtilisateurDe10Ans(): void + { + $dateNaissance = new DateTimeImmutable('2016-01-31'); + + self::assertTrue($this->policy->estRequis($dateNaissance)); + } + + #[Test] + public function consentementNonRequisPourUtilisateurDe15Ans(): void + { + $dateNaissance = new DateTimeImmutable('2011-01-30'); + + self::assertFalse($this->policy->estRequis($dateNaissance)); + } + + #[Test] + public function consentementNonRequisPourUtilisateurDe16Ans(): void + { + $dateNaissance = new DateTimeImmutable('2010-01-31'); + + self::assertFalse($this->policy->estRequis($dateNaissance)); + } + + #[Test] + public function consentementNonRequisSiDateNaissanceNulle(): void + { + self::assertFalse($this->policy->estRequis(null)); + } + + #[Test] + #[DataProvider('agesBordureProvider')] + public function consentementRequisAuxAgesBordure( + string $dateNaissance, + bool $consentementRequis, + string $description, + ): void { + $result = $this->policy->estRequis(new DateTimeImmutable($dateNaissance)); + + self::assertSame($consentementRequis, $result, $description); + } + + /** + * @return iterable + */ + public static function agesBordureProvider(): iterable + { + // Current date is 2026-01-31 + yield '14 ans et 364 jours' => [ + '2011-02-01', + true, + 'Un jour avant 15 ans → consentement requis', + ]; + + yield '15 ans exactement' => [ + '2011-01-31', + false, + 'Le jour des 15 ans → consentement non requis', + ]; + + yield '15 ans et 1 jour' => [ + '2011-01-30', + false, + 'Un jour après 15 ans → consentement non requis', + ]; + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ActivateAccountProcessorTest.php b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ActivateAccountProcessorTest.php new file mode 100644 index 0000000..a9cc96d --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Api/Processor/ActivateAccountProcessorTest.php @@ -0,0 +1,190 @@ +tokenRepository = new InMemoryActivationTokenRepository(); + $this->clock = new class implements Clock { + public function now(): DateTimeImmutable + { + return new DateTimeImmutable('2026-01-16 10:00:00'); + } + }; + } + + #[Test] + public function tokenRemainsValidWhenUserNotFound(): void + { + // Arrange: Create a valid token + $token = $this->createAndSaveToken(); + $tokenValue = $token->tokenValue; + + // Create processor with a UserRepository that throws UserNotFoundException + $processor = $this->createProcessorWithMissingUser(); + + $input = new ActivateAccountInput(); + $input->tokenValue = $tokenValue; + $input->password = self::PASSWORD; + + // Act: Try to activate (should fail because user not found) + try { + $processor->process($input, new Post()); + self::fail('Expected NotFoundHttpException to be thrown'); + } catch (NotFoundHttpException $e) { + self::assertSame('Utilisateur introuvable.', $e->getMessage()); + } + + // Assert: Token should NOT be consumed - retry should be possible + $tokenAfterFailure = $this->tokenRepository->findByTokenValue($tokenValue); + self::assertNotNull($tokenAfterFailure, 'Token should still exist after failed activation'); + self::assertFalse($tokenAfterFailure->isUsed(), 'Token should NOT be marked as used after failed activation'); + } + + #[Test] + public function tokenCanBeReusedAfterFailedActivation(): void + { + // Arrange: Create a valid token + $token = $this->createAndSaveToken(); + $tokenValue = $token->tokenValue; + + $processorWithMissingUser = $this->createProcessorWithMissingUser(); + + $input = new ActivateAccountInput(); + $input->tokenValue = $tokenValue; + $input->password = self::PASSWORD; + + // Act: First activation fails (user not found) + try { + $processorWithMissingUser->process($input, new Post()); + } catch (NotFoundHttpException) { + // Expected + } + + // Assert: Can call handler again with same token (retry scenario) + $handler = $this->createHandler(); + $result = ($handler)(new \App\Administration\Application\Command\ActivateAccount\ActivateAccountCommand( + tokenValue: $tokenValue, + password: self::PASSWORD, + )); + + // Should succeed - token was not burned + self::assertSame(self::USER_ID, $result->userId); + } + + private function createAndSaveToken(): ActivationToken + { + $token = ActivationToken::generate( + userId: self::USER_ID, + email: self::EMAIL, + tenantId: TenantId::fromString(self::TENANT_ID), + role: self::ROLE, + schoolName: self::SCHOOL_NAME, + createdAt: new DateTimeImmutable('2026-01-15 10:00:00'), + ); + + $this->tokenRepository->save($token); + + return $token; + } + + private function createHandler(): ActivateAccountHandler + { + $passwordHasher = new class implements PasswordHasher { + public function hash(string $plainPassword): string + { + return '$argon2id$hashed'; + } + + public function verify(string $hashedPassword, string $plainPassword): bool + { + return true; + } + }; + + return new ActivateAccountHandler( + $this->tokenRepository, + $passwordHasher, + $this->clock, + ); + } + + private function createProcessorWithMissingUser(): ActivateAccountProcessor + { + $handler = $this->createHandler(); + + // UserRepository that always throws UserNotFoundException + $userRepository = new class implements UserRepository { + public function save(\App\Administration\Domain\Model\User\User $user): void + { + } + + public function findById(UserId $id): ?\App\Administration\Domain\Model\User\User + { + return null; + } + + public function findByEmail(\App\Administration\Domain\Model\User\Email $email): ?\App\Administration\Domain\Model\User\User + { + return null; + } + + public function get(UserId $id): \App\Administration\Domain\Model\User\User + { + throw UserNotFoundException::withId($id); + } + }; + + $consentementPolicy = new ConsentementParentalPolicy($this->clock); + + $eventBus = $this->createMock(MessageBusInterface::class); + + return new ActivateAccountProcessor( + $handler, + $userRepository, + $this->tokenRepository, + $consentementPolicy, + $this->clock, + $eventBus, + ); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Persistence/Cache/CacheUserRepositoryTest.php b/backend/tests/Unit/Administration/Infrastructure/Persistence/Cache/CacheUserRepositoryTest.php new file mode 100644 index 0000000..d8963fd --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Persistence/Cache/CacheUserRepositoryTest.php @@ -0,0 +1,160 @@ +createMock(CacheItemInterface::class); + $cacheItem->method('set')->willReturnSelf(); + $cacheItem->method('expiresAfter') + ->willReturnCallback(static function ($ttl) use (&$expirationSet, $cacheItem) { + $expirationSet = $ttl; + + return $cacheItem; + }); + + $cachePool = $this->createMock(CacheItemPoolInterface::class); + $cachePool->method('getItem')->willReturn($cacheItem); + $cachePool->method('save')->willReturn(true); + + $repository = new CacheUserRepository($cachePool); + + $user = User::creer( + email: new Email('test@example.com'), + role: Role::PARENT, + tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'), + schoolName: 'École Test', + dateNaissance: null, + createdAt: new DateTimeImmutable(), + ); + + // Act + $repository->save($user); + + // Assert: No expiration should be set (expiresAfter should not be called with a TTL) + // The users.cache pool is configured with default_lifetime: 0 (no expiration) + // But CacheUserRepository should NOT explicitly set any TTL + self::assertNull( + $expirationSet, + 'User cache entries should not have explicit expiration set by the repository' + ); + } + + #[Test] + public function userCanBeRetrievedById(): void + { + // Arrange + $userId = '550e8400-e29b-41d4-a716-446655440001'; + $email = 'test@example.com'; + $tenantId = '550e8400-e29b-41d4-a716-446655440002'; + + $userData = [ + 'id' => $userId, + 'email' => $email, + 'role' => 'ROLE_PARENT', + 'tenant_id' => $tenantId, + 'school_name' => 'École Test', + 'statut' => 'pending', + 'hashed_password' => null, + 'date_naissance' => null, + 'created_at' => '2026-01-15T10:00:00+00:00', + 'activated_at' => null, + 'consentement_parental' => null, + ]; + + $cacheItem = $this->createMock(CacheItemInterface::class); + $cacheItem->method('isHit')->willReturn(true); + $cacheItem->method('get')->willReturn($userData); + + $cachePool = $this->createMock(CacheItemPoolInterface::class); + $cachePool->method('getItem')->willReturn($cacheItem); + + $repository = new CacheUserRepository($cachePool); + + // Act + $user = $repository->findById(\App\Administration\Domain\Model\User\UserId::fromString($userId)); + + // Assert + self::assertNotNull($user); + self::assertSame($userId, (string) $user->id); + self::assertSame($email, (string) $user->email); + self::assertSame(Role::PARENT, $user->role); + } + + #[Test] + public function userCanBeRetrievedByEmail(): void + { + // Arrange + $userId = '550e8400-e29b-41d4-a716-446655440001'; + $email = 'test@example.com'; + $tenantId = '550e8400-e29b-41d4-a716-446655440002'; + + $userData = [ + 'id' => $userId, + 'email' => $email, + 'role' => 'ROLE_PARENT', + 'tenant_id' => $tenantId, + 'school_name' => 'École Test', + 'statut' => 'pending', + 'hashed_password' => null, + 'date_naissance' => null, + 'created_at' => '2026-01-15T10:00:00+00:00', + 'activated_at' => null, + 'consentement_parental' => null, + ]; + + $emailIndexItem = $this->createMock(CacheItemInterface::class); + $emailIndexItem->method('isHit')->willReturn(true); + $emailIndexItem->method('get')->willReturn($userId); + + $userItem = $this->createMock(CacheItemInterface::class); + $userItem->method('isHit')->willReturn(true); + $userItem->method('get')->willReturn($userData); + + $cachePool = $this->createMock(CacheItemPoolInterface::class); + $cachePool->method('getItem') + ->willReturnCallback(static function ($key) use ($emailIndexItem, $userItem) { + if (str_starts_with($key, 'user_email:')) { + return $emailIndexItem; + } + + return $userItem; + }); + + $repository = new CacheUserRepository($cachePool); + + // Act + $user = $repository->findByEmail(new Email($email)); + + // Assert + self::assertNotNull($user); + self::assertSame($userId, (string) $user->id); + self::assertSame($email, (string) $user->email); + } +} diff --git a/backend/tests/Unit/Administration/Infrastructure/Persistence/InMemory/InMemoryActivationTokenRepositoryTest.php b/backend/tests/Unit/Administration/Infrastructure/Persistence/InMemory/InMemoryActivationTokenRepositoryTest.php new file mode 100644 index 0000000..c19da30 --- /dev/null +++ b/backend/tests/Unit/Administration/Infrastructure/Persistence/InMemory/InMemoryActivationTokenRepositoryTest.php @@ -0,0 +1,121 @@ +repository = new InMemoryActivationTokenRepository(); + } + + #[Test] + public function saveAndFindByTokenValue(): void + { + $token = $this->createToken(); + + $this->repository->save($token); + $found = $this->repository->findByTokenValue($token->tokenValue); + + self::assertSame($token, $found); + } + + #[Test] + public function saveAndGetById(): void + { + $token = $this->createToken(); + + $this->repository->save($token); + $found = $this->repository->get($token->id); + + self::assertSame($token, $found); + } + + #[Test] + public function findByTokenValueReturnsNullWhenNotFound(): void + { + $result = $this->repository->findByTokenValue('non-existent-token'); + + self::assertNull($result); + } + + #[Test] + public function getThrowsExceptionWhenNotFound(): void + { + $this->expectException(ActivationTokenNotFoundException::class); + + $this->repository->get(ActivationTokenId::generate()); + } + + #[Test] + public function deleteRemovesToken(): void + { + $token = $this->createToken(); + $this->repository->save($token); + + $this->repository->delete($token->id); + + self::assertNull($this->repository->findByTokenValue($token->tokenValue)); + } + + #[Test] + public function deleteRemovesTokenFromIdIndex(): void + { + $token = $this->createToken(); + $this->repository->save($token); + + $this->repository->delete($token->id); + + $this->expectException(ActivationTokenNotFoundException::class); + $this->repository->get($token->id); + } + + #[Test] + public function deleteNonExistentTokenDoesNotThrow(): void + { + $this->repository->delete(ActivationTokenId::generate()); + + $this->addToAssertionCount(1); // No exception thrown + } + + #[Test] + public function saveUpdatesExistingToken(): void + { + $token = $this->createToken(); + $this->repository->save($token); + + // Modify the token (mark as used) + $usedAt = new DateTimeImmutable('2026-01-16 10:00:00'); + $token->use($usedAt); + $this->repository->save($token); + + $found = $this->repository->findByTokenValue($token->tokenValue); + + self::assertTrue($found?->isUsed()); + } + + private function createToken(): ActivationToken + { + return ActivationToken::generate( + userId: '550e8400-e29b-41d4-a716-446655440001', + email: 'user@example.com', + tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440002'), + role: 'ROLE_PARENT', + schoolName: 'École Alpha', + createdAt: new DateTimeImmutable('2026-01-15 10:00:00'), + ); + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore index 83323da..01e67f2 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -35,3 +35,5 @@ dev-dist/ # ============================================================================= *.local *.tsbuildinfo +# Generated test token for E2E tests +e2e/.test-token diff --git a/frontend/e2e/activation.spec.ts b/frontend/e2e/activation.spec.ts new file mode 100644 index 0000000..16cf37a --- /dev/null +++ b/frontend/e2e/activation.spec.ts @@ -0,0 +1,187 @@ +import { test, expect } from '@playwright/test'; +import { getTestToken } from './test-utils'; + +test.describe('Account Activation Flow', () => { + test.describe('Token Validation', () => { + test('displays error for invalid token', async ({ page }) => { + await page.goto('/activate/invalid-token-uuid-format'); + + // Wait for the error state + await expect(page.getByRole('heading', { name: /lien invalide/i })).toBeVisible(); + await expect(page.getByText(/contacter votre établissement/i)).toBeVisible(); + }); + + test('displays error for non-existent token', async ({ page }) => { + // Use a valid UUID format but non-existent token + await page.goto('/activate/00000000-0000-0000-0000-000000000000'); + + // Shows error because token doesn't exist + const heading = page.getByRole('heading', { name: /lien invalide/i }); + await expect(heading).toBeVisible(); + }); + }); + + test.describe('Password Form', () => { + test('validates password requirements in real-time', async ({ page }) => { + const token = getTestToken(); + await page.goto(`/activate/${token}`); + + // Wait for form to be visible (token must be valid) + const form = page.locator('form'); + await expect(form).toBeVisible({ timeout: 5000 }); + + const passwordInput = page.locator('#password'); + + // Test minimum length requirement - should NOT be valid yet + await passwordInput.fill('Abc1'); + const minLengthItem = page.locator('.password-requirements li').filter({ hasText: /8 caractères/ }); + await expect(minLengthItem).not.toHaveClass(/valid/); + + // Test uppercase requirement - missing + await passwordInput.fill('abcd1234'); + const uppercaseItem = page.locator('.password-requirements li').filter({ hasText: /majuscule/ }); + await expect(uppercaseItem).not.toHaveClass(/valid/); + + // Test digit requirement - missing + await passwordInput.fill('Abcdefgh'); + const digitItem = page.locator('.password-requirements li').filter({ hasText: /chiffre/ }); + await expect(digitItem).not.toHaveClass(/valid/); + + // Valid password should show all checkmarks + await passwordInput.fill('Abcdefgh1'); + const validItems = page.locator('.password-requirements li.valid'); + await expect(validItems).toHaveCount(3); + }); + + test('requires password confirmation to match', async ({ page }) => { + const token = getTestToken(); + await page.goto(`/activate/${token}`); + + const form = page.locator('form'); + await expect(form).toBeVisible({ timeout: 5000 }); + + const passwordInput = page.locator('#password'); + const confirmInput = page.locator('#passwordConfirmation'); + + await passwordInput.fill('SecurePass123'); + await confirmInput.fill('DifferentPass123'); + + await expect(page.getByText(/mots de passe ne correspondent pas/i)).toBeVisible(); + + // Fix confirmation + await confirmInput.fill('SecurePass123'); + await expect(page.getByText(/mots de passe ne correspondent pas/i)).not.toBeVisible(); + }); + + test('submit button is disabled until form is valid', async ({ page }) => { + const token = getTestToken(); + await page.goto(`/activate/${token}`); + + const form = page.locator('form'); + await expect(form).toBeVisible({ timeout: 5000 }); + + const submitButton = page.getByRole('button', { name: /activer mon compte/i }); + + // Initially disabled + await expect(submitButton).toBeDisabled(); + + // Fill valid password + await page.locator('#password').fill('SecurePass123'); + await page.locator('#passwordConfirmation').fill('SecurePass123'); + + // Should now be enabled + await expect(submitButton).toBeEnabled(); + }); + }); + + test.describe('Establishment Info Display', () => { + test('shows establishment name and role when token is valid', async ({ page }) => { + const token = getTestToken(); + await page.goto(`/activate/${token}`); + + const form = page.locator('form'); + await expect(form).toBeVisible({ timeout: 5000 }); + + // School info should be visible + await expect(page.locator('.school-info')).toBeVisible(); + await expect(page.locator('.school-name')).toBeVisible(); + await expect(page.locator('.account-type')).toBeVisible(); + }); + }); + + test.describe('Password Visibility Toggle', () => { + test('toggles password visibility', async ({ page }) => { + const token = getTestToken(); + await page.goto(`/activate/${token}`); + + const form = page.locator('form'); + await expect(form).toBeVisible({ timeout: 5000 }); + + const passwordInput = page.locator('#password'); + const toggleButton = page.locator('.toggle-password'); + + // Initially password type + await expect(passwordInput).toHaveAttribute('type', 'password'); + + // Click toggle + await toggleButton.click(); + await expect(passwordInput).toHaveAttribute('type', 'text'); + + // Click again to hide + await toggleButton.click(); + await expect(passwordInput).toHaveAttribute('type', 'password'); + }); + }); + + test.describe('Full Activation Flow', () => { + test('activates account and redirects to login', async ({ page }) => { + const token = getTestToken(); + await page.goto(`/activate/${token}`); + + const form = page.locator('form'); + await expect(form).toBeVisible({ timeout: 5000 }); + + // Fill valid password + await page.locator('#password').fill('SecurePass123'); + await page.locator('#passwordConfirmation').fill('SecurePass123'); + + // Submit + await page.getByRole('button', { name: /activer mon compte/i }).click(); + + // Should redirect to login with success message + await expect(page).toHaveURL(/\/login\?activated=true/); + await expect(page.getByText(/compte a été activé avec succès/i)).toBeVisible(); + }); + }); +}); + +test.describe('Login Page After Activation', () => { + test('shows success message when redirected after activation', async ({ page }) => { + await page.goto('/login?activated=true'); + + await expect(page.getByText(/compte a été activé avec succès/i)).toBeVisible(); + await expect(page.getByRole('heading', { name: /connexion/i })).toBeVisible(); + }); + + test('does not show success message without query param', async ({ page }) => { + await page.goto('/login'); + + await expect(page.getByText(/compte a été activé avec succès/i)).not.toBeVisible(); + await expect(page.getByRole('heading', { name: /connexion/i })).toBeVisible(); + }); +}); + +test.describe('Parental Consent Flow (Minor User)', () => { + // These tests would require seeded data for a minor user + test.skip('shows consent required message for minor without consent', async () => { + // Would navigate to activation page for a minor user token + // and verify the consent required message is displayed + }); + + test.skip('allows activation after parental consent is given', async () => { + // Would verify the full flow: + // 1. Minor receives activation link + // 2. Parent gives consent + // 3. Minor can then activate their account + }); +}); diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts new file mode 100644 index 0000000..f88fe81 --- /dev/null +++ b/frontend/e2e/global-setup.ts @@ -0,0 +1,52 @@ +import { execSync } from 'child_process'; +import { writeFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * Global setup for E2E tests. + * Seeds a test activation token before tests run. + */ +async function globalSetup() { + console.warn('🌱 Seeding test activation token...'); + + try { + // Call the backend command to create a test token + // Project root is 2 levels up from frontend/e2e/ + const projectRoot = join(__dirname, '../..'); + const composeFile = join(projectRoot, 'compose.yaml'); + + const result = execSync( + `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-activation-token --email=e2e-test@example.com 2>&1`, + { + encoding: 'utf-8' + } + ); + + // Extract the token from the output + // Output format: "Token f9174245-9766-4ef1-b6e9-a6795aa2da04" + const tokenMatch = result.match(/Token\s+([a-f0-9-]{36})/i); + if (!tokenMatch) { + console.error('❌ Could not extract token from output:', result); + throw new Error('Failed to extract token from command output'); + } + + const token = tokenMatch[1]; + console.warn(`✅ Test token created: ${token}`); + + // Write the token to a file for tests to use + const tokenFile = join(__dirname, '.test-token'); + writeFileSync(tokenFile, token); + + console.warn('✅ Token saved to .test-token file'); + } catch (error) { + console.error('❌ Failed to seed test token:', error); + // Don't throw - tests can still run with skipped token-dependent tests + console.warn('⚠️ Tests requiring valid tokens will be skipped'); + } +} + +export default globalSetup; diff --git a/frontend/e2e/test-utils.ts b/frontend/e2e/test-utils.ts new file mode 100644 index 0000000..b596246 --- /dev/null +++ b/frontend/e2e/test-utils.ts @@ -0,0 +1,22 @@ +import { readFileSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * Get the seeded test token. + * The token is created by global-setup.ts before tests run via Docker. + */ +export function getTestToken(): string { + const tokenFile = join(__dirname, '.test-token'); + + if (existsSync(tokenFile)) { + return readFileSync(tokenFile, 'utf-8').trim(); + } + + throw new Error( + 'No .test-token file found. Make sure Docker is running and global-setup.ts executed successfully.' + ); +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 9fca5eb..e687794 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -73,7 +73,10 @@ export default tseslint.config( process: 'readonly', Promise: 'readonly', Set: 'readonly', - Map: 'readonly' + Map: 'readonly', + Event: 'readonly', + SubmitEvent: 'readonly', + fetch: 'readonly' } }, plugins: { diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index d46373b..285bae9 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,15 +1,23 @@ import type { PlaywrightTestConfig } from '@playwright/test'; +const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173'; +const useExternalServer = !!process.env.PLAYWRIGHT_BASE_URL; + const config: PlaywrightTestConfig = { - webServer: { - command: 'pnpm run build && pnpm run preview', - port: 4173, - reuseExistingServer: !process.env.CI - }, + // Always run globalSetup to seed test tokens + // If backend is not running, tests requiring tokens will be skipped gracefully + globalSetup: './e2e/global-setup.ts', + webServer: useExternalServer + ? undefined + : { + command: 'pnpm run build && pnpm run preview', + port: 4173, + reuseExistingServer: !process.env.CI + }, testDir: 'e2e', testMatch: /(.+\.)?(test|spec)\.[jt]s/, use: { - baseURL: 'http://localhost:4173', + baseURL, trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure' diff --git a/frontend/src/lib/types/activation.ts b/frontend/src/lib/types/activation.ts new file mode 100644 index 0000000..5f9ad27 --- /dev/null +++ b/frontend/src/lib/types/activation.ts @@ -0,0 +1,41 @@ +/** + * Types for account activation flow. + */ + +export interface ActivationTokenInfo { + tokenValue: string; + email: string; + role: string; + schoolName: string; + isExpired: boolean; + expiresAt: string; +} + +export interface ActivateAccountInput { + tokenValue: string; + password: string; +} + +export interface ActivateAccountOutput { + userId: string; + email: string; + role: string; + message: string; +} + +export type ActivationError = + | 'TOKEN_NOT_FOUND' + | 'TOKEN_EXPIRED' + | 'TOKEN_ALREADY_USED' + | 'VALIDATION_ERROR' + | 'NETWORK_ERROR'; + +export interface ActivationErrorResponse { + '@type': string; + title: string; + detail: string; + violations?: Array<{ + propertyPath: string; + message: string; + }>; +} diff --git a/frontend/src/routes/activate/[token]/+page.svelte b/frontend/src/routes/activate/[token]/+page.svelte new file mode 100644 index 0000000..97b9896 --- /dev/null +++ b/frontend/src/routes/activate/[token]/+page.svelte @@ -0,0 +1,649 @@ + + + + Activation de compte | Classeo + + + + + +
+
+ + + + {#if $tokenInfoQuery.isPending} + +
+
+
+

Vérification du lien d'activation...

+
+
+ {:else if $tokenInfoQuery.isError} + +
+
+
+

Lien invalide

+

Ce lien d'activation est invalide ou a expiré.

+

Veuillez contacter votre établissement pour obtenir un nouveau lien.

+
+
+ {:else if $tokenInfoQuery.data} + {@const tokenInfo = $tokenInfoQuery.data} + + {#if tokenInfo.isExpired} + +
+
+
+

Lien expiré

+

Votre lien d'activation a expiré (validité : 7 jours).

+

+ Veuillez contacter {tokenInfo.schoolName} pour obtenir un nouveau lien. +

+
+
+ {:else} + +
+

Activation de votre compte

+ + +
+
🏫
+
+ {tokenInfo.schoolName} + +
+
+ + + +
+ {#if formError} +
+ ! + {formError} +
+ {/if} + + +
+ +
+ + +
+ {#if fieldErrors['password']} + {fieldErrors['password']} + {/if} +
+ + +
+ Votre mot de passe doit contenir : +
    +
  • + {hasMinLength ? '✓' : '○'} + Au moins 8 caractères +
  • +
  • + {hasUppercase ? '✓' : '○'} + Au moins 1 majuscule +
  • +
  • + {hasDigit ? '✓' : '○'} + Au moins 1 chiffre +
  • +
+
+ + +
+ +
+ 0 && !passwordsMatch} + /> +
+ {#if passwordConfirmation.length > 0 && !passwordsMatch} + Les mots de passe ne correspondent pas. + {/if} +
+ + + +
+
+ {/if} + {/if} + + + +
+
+ + diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..c87c75a --- /dev/null +++ b/frontend/src/routes/login/+page.svelte @@ -0,0 +1,257 @@ + + + + Connexion | Classeo + + + + + + + +