diff --git a/.env.dist b/.env.dist index d272cfa..580aedd 100644 --- a/.env.dist +++ b/.env.dist @@ -59,3 +59,10 @@ MERCURE_SUBSCRIBER_JWT=changethis WEBAUTHN_RP_ID=mine.local WEBAUTHN_ORIGIN=https://mine.local ###< web-auth/webauthn-framework ### + +###> symfony/messenger ### +# Choose one of the transports below +# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages +# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages +MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 +###< symfony/messenger ### diff --git a/compose.yaml b/compose.yaml index fc3b7ca..867d4e1 100644 --- a/compose.yaml +++ b/compose.yaml @@ -47,6 +47,45 @@ services: condition: service_healthy mail: condition: service_started + scheduler: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + container_name: '${APP_NAME}-scheduler' + command: ['php', 'bin/console', 'messenger:consume', 'scheduler_default', '--env=prod', '--no-interaction', '--memory-limit=128M'] + environment: + APP_ENV: prod + APP_SECRET: ${APP_SECRET} + APP_PUBLIC_HOSTNAME: ${APP_PUBLIC_HOSTNAME:-localhost} + DATABASE_URL: >- + postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?serverVersion=${POSTGRES_VERSION}&charset=utf8 + POSTGRES_URL: db + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + MERCURE_URL: http://localhost/.well-known/mercure + MERCURE_PUBLIC_URL: https://${APP_PUBLIC_HOSTNAME:-localhost}/.well-known/mercure + MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET} + MERCURE_JWT_TOKEN: ${MERCURE_JWT_TOKEN} + MERCURE_SUBSCRIBER_JWT: ${MERCURE_SUBSCRIBER_JWT} + MAILER_DSN: smtp://mail:25?verify_peer=0 + CAP_API_ENDPOINT: ${CAP_API_ENDPOINT} + CAP_SECRET_KEY: ${CAP_SECRET_KEY} + WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost} + WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-https://localhost} + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + MINIO_ENDPOINT: http://minio:9000 + MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000} + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1} + volumes: + - app_var:/app/var + depends_on: + db: + condition: service_healthy + mail: + condition: service_started minio: image: minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1 restart: unless-stopped diff --git a/composer.json b/composer.json index 8e85ebb..ecf48df 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,9 @@ "symfony/mailer": "7.4.*", "symfony/mercure": "^0.7", "symfony/mercure-bundle": "^0.4", + "symfony/messenger": "7.4.*", "symfony/monolog-bundle": "^4.0", + "symfony/scheduler": "7.4.*", "symfony/security-bundle": "7.4.*", "symfony/translation": "7.4.*", "symfony/twig-bundle": "7.4.*", diff --git a/composer.lock b/composer.lock index 0df32fe..c47396d 100644 --- a/composer.lock +++ b/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": "28a735d6ad91ee32b02bc9233c94145f", + "content-hash": "a571ebed22d0d760c9585870cc15eb19", "packages": [ { "name": "aws/aws-crt-php", @@ -6107,6 +6107,100 @@ ], "time": "2025-11-25T12:51:49+00:00" }, + { + "name": "symfony/messenger", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/messenger.git", + "reference": "1ebe448527c77d9efaf2d3205b23707ebaaa28c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/messenger/zipball/1ebe448527c77d9efaf2d3205b23707ebaaa28c4", + "reference": "1ebe448527c77d9efaf2d3205b23707ebaaa28c4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<7.2", + "symfony/event-dispatcher": "<6.4", + "symfony/event-dispatcher-contracts": "<2.5", + "symfony/framework-bundle": "<6.4", + "symfony/http-kernel": "<7.3", + "symfony/lock": "<7.4", + "symfony/serializer": "<6.4.32|>=7.3,<7.3.10|>=7.4,<7.4.4|>=8.0,<8.0.4" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/console": "^7.2|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^7.3|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.32|~7.3.10|^7.4.4|^8.0.4", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Samuel Roze", + "email": "samuel.roze@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps applications send and receive messages to/from other applications or via message queues", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/messenger/tree/v7.4.14" + }, + "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-06-08T20:24:16+00:00" + }, { "name": "symfony/mime", "version": "v7.4.13", @@ -7576,6 +7670,91 @@ ], "time": "2026-05-24T11:20:33+00:00" }, + { + "name": "symfony/scheduler", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/scheduler.git", + "reference": "d8fff93e5d29af0262e5693b76376117259b4532" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/scheduler/zipball/d8fff93e5d29af0262e5693b76376117259b4532", + "reference": "d8fff93e5d29af0262e5693b76376117259b4532", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/clock": "^6.4|^7.0|^8.0" + }, + "require-dev": { + "dragonmantank/cron-expression": "^3.1", + "symfony/cache": "^6.4.36|^7.4.8|^8.0.8", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.1|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Scheduler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sergey Rabochiy", + "email": "upyx.00@gmail.com" + }, + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides scheduling through Symfony Messenger", + "homepage": "https://symfony.com", + "keywords": [ + "cron", + "schedule", + "scheduler" + ], + "support": { + "source": "https://github.com/symfony/scheduler/tree/v7.4.13" + }, + "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-05-25T06:06:12+00:00" + }, { "name": "symfony/security-bundle", "version": "v7.4.13", diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml new file mode 100644 index 0000000..19db483 --- /dev/null +++ b/config/packages/messenger.yaml @@ -0,0 +1,22 @@ +framework: + messenger: + # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling. + # failure_transport: failed + + transports: + # https://symfony.com/doc/current/messenger.html#transport-configuration + # async: '%env(MESSENGER_TRANSPORT_DSN)%' + # failed: 'doctrine://default?queue_name=failed' + sync: 'sync://' + + routing: + # Route your messages to the transports + # 'App\Message\YourMessage': async + +# when@test: +# framework: +# messenger: +# transports: +# # replace with your transport name here (e.g., my_transport: 'in-memory://') +# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test +# async: 'in-memory://' diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 502d555..7cfd677 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -8,6 +8,11 @@ until php -r "new PDO('pgsql:host=db;port=5432;dbname=${POSTGRES_DB}', '${POSTGR done echo "[entrypoint] Database is ready." +if [ "$#" -gt 0 ]; then + echo "[entrypoint] Starting worker command: $*" + exec "$@" +fi + echo "[entrypoint] Clearing and warming Symfony cache..." php bin/console cache:clear diff --git a/docs/README.md b/docs/README.md index 7cb7a2d..872c079 100644 --- a/docs/README.md +++ b/docs/README.md @@ -57,6 +57,9 @@ Quick command reference for testing: ## CI/CD & Deployment +### [Scheduled Tasks](./SCHEDULER.md) +Symfony Scheduler tasks and the Docker worker that runs them. + ### [CI/CD Integration Guide](./CI_CD.md) Comprehensive guide for continuous integration and deployment: - Gitea Actions workflows (CI and CD pipelines) diff --git a/docs/SCHEDULER.md b/docs/SCHEDULER.md new file mode 100644 index 0000000..e33ba1c --- /dev/null +++ b/docs/SCHEDULER.md @@ -0,0 +1,31 @@ +# Scheduled Tasks + +MineSeeker uses Symfony Scheduler for recurring maintenance work. The Docker +Compose `scheduler` service runs the Scheduler worker continuously and is +restarted automatically if it exits. + +## Pending-account cleanup + +`PurgeExpiredPendingUsersCommand` is scheduled every hour. It deletes only +unverified users whose activation-token expiry has passed. + +To inspect the registered schedule locally: + +```bash +php bin/console debug:scheduler +``` + +To run the cleanup manually without deleting data: + +```bash +php bin/console app:users:purge-expired-pending --dry-run +``` + +The production worker is started by Docker Compose: + +```bash +docker compose up -d scheduler +docker compose logs -f scheduler +``` + +No host-level cron entry is required. diff --git a/src/Command/PurgeExpiredPendingUsersCommand.php b/src/Command/PurgeExpiredPendingUsersCommand.php new file mode 100644 index 0000000..4484db5 --- /dev/null +++ b/src/Command/PurgeExpiredPendingUsersCommand.php @@ -0,0 +1,78 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 07. 27. + */ +#[AsCommand( + name: 'app:users:purge-expired-pending', + description: 'Delete unverified accounts whose activation token has expired.', +)] +#[AsPeriodicTask(frequency: '1 hour')] +final class PurgeExpiredPendingUsersCommand extends Command +{ + public function __construct(private readonly UserRepository $userRepository) + { + parent::__construct(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $now = new DateTime(); + $count = $this->userRepository->countExpiredPendingUsers($now); + + if ($input->getOption('dry-run')) { + $io->note(sprintf('%d expired pending account(s) would be deleted.', $count)); + + return Command::SUCCESS; + } + + if ($count === 0) { + $io->success('No expired pending accounts found.'); + + return Command::SUCCESS; + } + + $deleted = $this->userRepository->deleteExpiredPendingUsers($now); + $io->success(sprintf('Deleted %d expired pending account(s).', $deleted)); + + return Command::SUCCESS; + } + + protected function configure(): void + { + $this->addOption( + 'dry-run', + null, + InputOption::VALUE_NONE, + 'Report how many accounts would be deleted without deleting them.', + ); + } +} diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index cf42030..729ed64 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -44,6 +44,8 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; #[AsController] class SecurityController extends AbstractController { + private const string ACTIVATION_TOKEN_TTL = '+24 hours'; + public function __construct( private readonly EntityManagerInterface $em, private readonly RequestStack $requestStack, @@ -92,6 +94,7 @@ class SecurityController extends AbstractController $user->isVerified = false; $user->verificationToken = $token; + $user->verificationTokenExpiresAt = new DateTime(self::ACTIVATION_TOKEN_TTL); $user->password = $this->passwordHasher->hashPassword($user, $form->get('plainPassword')->getData()); $this->em->persist($user); @@ -194,13 +197,14 @@ class SecurityController extends AbstractController { $user = $this->em->getRepository(User::class)->findOneBy(['verificationToken' => $token]); - if (!$user) { - $this->addFlash('error', 'This activation link is invalid or has already been used.'); + if (!$user || $user->verificationTokenExpiresAt === null || $user->verificationTokenExpiresAt <= new DateTime()) { + $this->addFlash('error', 'This activation link is invalid, expired, or has already been used.'); return $this->redirectToRoute('MineSeekerBundle_login'); } $user->isVerified = true; $user->verificationToken = null; + $user->verificationTokenExpiresAt = null; $this->em->flush(); $this->activationNotificationEmail->send($user, new DateTime()); diff --git a/src/Entity/User.php b/src/Entity/User.php index 8e1a6ac..fa79c54 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -62,6 +62,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, TotpTwo #[Column(length: 64, nullable: true)] public ?string $verificationToken = null; + #[Column(type: Types::DATETIME_MUTABLE, nullable: true)] + public ?DateTime $verificationTokenExpiresAt = null; + #[Column(length: 64, nullable: true)] public ?string $resetToken = null; diff --git a/src/Migrations/2026/07/Version20260727120000.php b/src/Migrations/2026/07/Version20260727120000.php new file mode 100644 index 0000000..05be071 --- /dev/null +++ b/src/Migrations/2026/07/Version20260727120000.php @@ -0,0 +1,45 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 07. 27. + */ +final class Version20260727120000 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Add expiry timestamps for account activation tokens.'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE app_user ADD verification_token_expires_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql("UPDATE app_user SET verification_token_expires_at = CURRENT_TIMESTAMP + INTERVAL '24 hours' WHERE is_verified = FALSE AND verification_token IS NOT NULL"); + $this->addSql('CREATE INDEX IDX_APP_USER_PENDING_ACTIVATION_EXPIRY ON app_user (verification_token_expires_at) WHERE is_verified = FALSE AND verification_token_expires_at IS NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX IDX_APP_USER_PENDING_ACTIVATION_EXPIRY'); + $this->addSql('ALTER TABLE app_user DROP verification_token_expires_at'); + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index a95e3b3..8ca3af9 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -11,6 +11,7 @@ namespace App\Repository; use App\Entity\User; +use DateTimeInterface; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\NonUniqueResultException; use Doctrine\Persistence\ManagerRegistry; @@ -87,6 +88,34 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader } } + public function countExpiredPendingUsers(DateTimeInterface $now): int + { + $qb = $this->createQueryBuilder('u'); + + return (int) $qb + ->select($qb->expr()->count('u.id')) + ->where($qb->expr()->eq('u.isVerified', ':isVerified')) + ->andWhere($qb->expr()->lte('u.verificationTokenExpiresAt', ':now')) + ->setParameter('isVerified', false) + ->setParameter('now', $now) + ->getQuery() + ->getSingleScalarResult(); + } + + public function deleteExpiredPendingUsers(DateTimeInterface $now): int + { + $qb = $this->createQueryBuilder('u'); + + return $qb + ->delete() + ->where($qb->expr()->eq('u.isVerified', ':isVerified')) + ->andWhere($qb->expr()->lte('u.verificationTokenExpiresAt', ':now')) + ->setParameter('isVerified', false) + ->setParameter('now', $now) + ->getQuery() + ->execute(); + } + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void { if (!$user instanceof User) { diff --git a/src/Schedule.php b/src/Schedule.php new file mode 100644 index 0000000..37a45e3 --- /dev/null +++ b/src/Schedule.php @@ -0,0 +1,35 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 07. 27. + */ +#[AsSchedule] +class Schedule implements ScheduleProviderInterface +{ + public function __construct(private CacheInterface $cache) { } + + public function getSchedule(): SymfonySchedule + { + return (new SymfonySchedule()) + ->stateful($this->cache) // ensure missed tasks are executed + ->processOnlyLastMissedRun(true) // ensure only last missed task is run + + // add your own tasks here + // see https://symfony.com/doc/current/scheduler.html#attaching-recurring-messages-to-a-schedule + ; + } +} diff --git a/symfony.lock b/symfony.lock index dd9a62e..1ae1401 100644 --- a/symfony.lock +++ b/symfony.lock @@ -316,6 +316,18 @@ "config/packages/mercure.yaml" ] }, + "symfony/messenger": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.0", + "ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b" + }, + "files": [ + "config/packages/messenger.yaml" + ] + }, "symfony/mime": { "version": "v4.3.5" }, @@ -375,6 +387,18 @@ "ref": "cda8b550123383d25827705d05a42acf6819fe4e" } }, + "symfony/scheduler": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.2", + "ref": "caea3c928ee9e1b21288fd76aef36f16ea355515" + }, + "files": [ + "src/Schedule.php" + ] + }, "symfony/security": { "version": "v4.0.9" }, diff --git a/tests/Command/PurgeExpiredPendingUsersCommandTest.php b/tests/Command/PurgeExpiredPendingUsersCommandTest.php new file mode 100644 index 0000000..dbe30e9 --- /dev/null +++ b/tests/Command/PurgeExpiredPendingUsersCommandTest.php @@ -0,0 +1,73 @@ + false, + 'verificationToken' => 'expired-token', + 'verificationTokenExpiresAt' => new DateTime('-1 minute'), + ]); + $pendingUser = UserFactory::createOne([ + 'isVerified' => false, + 'verificationToken' => 'valid-token', + 'verificationTokenExpiresAt' => new DateTime('+1 day'), + ]); + $verifiedUser = UserFactory::createOne([ + 'isVerified' => true, + 'verificationToken' => 'verified-token', + 'verificationTokenExpiresAt' => new DateTime('-1 minute'), + ]); + + $command = static::getContainer()->get(PurgeExpiredPendingUsersCommand::class); + $tester = new CommandTester($command); + $tester->execute([]); + + $em = static::getContainer()->get(EntityManagerInterface::class); + self::assertNull($em->find(User::class, $expiredUser->_real()->id)); + self::assertNotNull($em->find(User::class, $pendingUser->_real()->id)); + self::assertNotNull($em->find(User::class, $verifiedUser->_real()->id)); + } + + #[Test] + #[TestDox('Dry run keeps expired unverified users')] + public function dryRunDoesNotDeleteExpiredUsers(): void + { + $expiredUser = UserFactory::createOne([ + 'isVerified' => false, + 'verificationToken' => 'expired-token', + 'verificationTokenExpiresAt' => new DateTime('-1 minute'), + ]); + + $command = static::getContainer()->get(PurgeExpiredPendingUsersCommand::class); + $tester = new CommandTester($command); + $tester->execute(['--dry-run' => true]); + + $em = static::getContainer()->get(EntityManagerInterface::class); + self::assertNotNull($em->find(User::class, $expiredUser->_real()->id)); + } +} diff --git a/tests/Controller/SecurityControllerTest.php b/tests/Controller/SecurityControllerTest.php index 427ecd5..4f37585 100644 --- a/tests/Controller/SecurityControllerTest.php +++ b/tests/Controller/SecurityControllerTest.php @@ -10,6 +10,8 @@ namespace App\Tests\Controller; +use DateTime; +use Doctrine\ORM\EntityManagerInterface; use App\Tests\Factory\UserFactory; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestDox; @@ -103,6 +105,25 @@ class SecurityControllerTest extends WebTestCase self::assertResponseRedirects('/login'); } + #[Test] + #[TestDox('Expired account activation token does not activate the account')] + public function expiredActivationTokenDoesNotActivateAccount(): void + { + $user = UserFactory::createOne([ + 'isVerified' => false, + 'verificationToken' => 'expired-activation-token', + 'verificationTokenExpiresAt' => new DateTime('-1 minute'), + ]); + + $this->client->request('GET', '/activate/expired-activation-token'); + + self::assertResponseRedirects('/login'); + + static::getContainer()->get(EntityManagerInterface::class)->refresh($user->_real()); + self::assertFalse($user->isVerified); + self::assertSame('expired-activation-token', $user->verificationToken); + } + #[Test] #[TestDox('Password reset with invalid token redirects to forgot password')] public function resetPasswordWithInvalidTokenShowsError(): void