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 + + + + + + + +