new: dev: hardening the registration process with removing the not activated registrations #14

This commit is contained in:
2026-07-27 18:08:52 +02:00
parent f3e4ff211d
commit 9ea83d7e6e
17 changed files with 603 additions and 3 deletions
+7
View File
@@ -59,3 +59,10 @@ MERCURE_SUBSCRIBER_JWT=changethis
WEBAUTHN_RP_ID=mine.local WEBAUTHN_RP_ID=mine.local
WEBAUTHN_ORIGIN=https://mine.local WEBAUTHN_ORIGIN=https://mine.local
###< web-auth/webauthn-framework ### ###< 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 ###
+39
View File
@@ -47,6 +47,45 @@ services:
condition: service_healthy condition: service_healthy
mail: mail:
condition: service_started 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: minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1 image: minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1
restart: unless-stopped restart: unless-stopped
+2
View File
@@ -35,7 +35,9 @@
"symfony/mailer": "7.4.*", "symfony/mailer": "7.4.*",
"symfony/mercure": "^0.7", "symfony/mercure": "^0.7",
"symfony/mercure-bundle": "^0.4", "symfony/mercure-bundle": "^0.4",
"symfony/messenger": "7.4.*",
"symfony/monolog-bundle": "^4.0", "symfony/monolog-bundle": "^4.0",
"symfony/scheduler": "7.4.*",
"symfony/security-bundle": "7.4.*", "symfony/security-bundle": "7.4.*",
"symfony/translation": "7.4.*", "symfony/translation": "7.4.*",
"symfony/twig-bundle": "7.4.*", "symfony/twig-bundle": "7.4.*",
Generated
+180 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "28a735d6ad91ee32b02bc9233c94145f", "content-hash": "a571ebed22d0d760c9585870cc15eb19",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@@ -6107,6 +6107,100 @@
], ],
"time": "2025-11-25T12:51:49+00:00" "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", "name": "symfony/mime",
"version": "v7.4.13", "version": "v7.4.13",
@@ -7576,6 +7670,91 @@
], ],
"time": "2026-05-24T11:20:33+00:00" "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", "name": "symfony/security-bundle",
"version": "v7.4.13", "version": "v7.4.13",
+22
View File
@@ -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://'
+5
View File
@@ -8,6 +8,11 @@ until php -r "new PDO('pgsql:host=db;port=5432;dbname=${POSTGRES_DB}', '${POSTGR
done done
echo "[entrypoint] Database is ready." echo "[entrypoint] Database is ready."
if [ "$#" -gt 0 ]; then
echo "[entrypoint] Starting worker command: $*"
exec "$@"
fi
echo "[entrypoint] Clearing and warming Symfony cache..." echo "[entrypoint] Clearing and warming Symfony cache..."
php bin/console cache:clear php bin/console cache:clear
+3
View File
@@ -57,6 +57,9 @@ Quick command reference for testing:
## CI/CD & Deployment ## CI/CD & Deployment
### [Scheduled Tasks](./SCHEDULER.md)
Symfony Scheduler tasks and the Docker worker that runs them.
### [CI/CD Integration Guide](./CI_CD.md) ### [CI/CD Integration Guide](./CI_CD.md)
Comprehensive guide for continuous integration and deployment: Comprehensive guide for continuous integration and deployment:
- Gitea Actions workflows (CI and CD pipelines) - Gitea Actions workflows (CI and CD pipelines)
+31
View File
@@ -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.
@@ -0,0 +1,78 @@
<?php declare(strict_types=1);
/*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Command;
use App\Repository\UserRepository;
use DateTime;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Scheduler\Attribute\AsPeriodicTask;
/**
* Class PurgeExpiredPendingUsersCommand
*
* @package App\Command
* @author Lang <https://www.splendidbear.org>
* @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.',
);
}
}
+6 -2
View File
@@ -44,6 +44,8 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
#[AsController] #[AsController]
class SecurityController extends AbstractController class SecurityController extends AbstractController
{ {
private const string ACTIVATION_TOKEN_TTL = '+24 hours';
public function __construct( public function __construct(
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly RequestStack $requestStack, private readonly RequestStack $requestStack,
@@ -92,6 +94,7 @@ class SecurityController extends AbstractController
$user->isVerified = false; $user->isVerified = false;
$user->verificationToken = $token; $user->verificationToken = $token;
$user->verificationTokenExpiresAt = new DateTime(self::ACTIVATION_TOKEN_TTL);
$user->password = $this->passwordHasher->hashPassword($user, $form->get('plainPassword')->getData()); $user->password = $this->passwordHasher->hashPassword($user, $form->get('plainPassword')->getData());
$this->em->persist($user); $this->em->persist($user);
@@ -194,13 +197,14 @@ class SecurityController extends AbstractController
{ {
$user = $this->em->getRepository(User::class)->findOneBy(['verificationToken' => $token]); $user = $this->em->getRepository(User::class)->findOneBy(['verificationToken' => $token]);
if (!$user) { if (!$user || $user->verificationTokenExpiresAt === null || $user->verificationTokenExpiresAt <= new DateTime()) {
$this->addFlash('error', 'This activation link is invalid or has already been used.'); $this->addFlash('error', 'This activation link is invalid, expired, or has already been used.');
return $this->redirectToRoute('MineSeekerBundle_login'); return $this->redirectToRoute('MineSeekerBundle_login');
} }
$user->isVerified = true; $user->isVerified = true;
$user->verificationToken = null; $user->verificationToken = null;
$user->verificationTokenExpiresAt = null;
$this->em->flush(); $this->em->flush();
$this->activationNotificationEmail->send($user, new DateTime()); $this->activationNotificationEmail->send($user, new DateTime());
+3
View File
@@ -62,6 +62,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, TotpTwo
#[Column(length: 64, nullable: true)] #[Column(length: 64, nullable: true)]
public ?string $verificationToken = null; public ?string $verificationToken = null;
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
public ?DateTime $verificationTokenExpiresAt = null;
#[Column(length: 64, nullable: true)] #[Column(length: 64, nullable: true)]
public ?string $resetToken = null; public ?string $resetToken = null;
@@ -0,0 +1,45 @@
<?php declare(strict_types=1);
/*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20260727120000
*
* @package App\Migrations
* @author Lang <https://www.splendidbear.org>
* @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');
}
}
+29
View File
@@ -11,6 +11,7 @@
namespace App\Repository; namespace App\Repository;
use App\Entity\User; use App\Entity\User;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\NonUniqueResultException;
use Doctrine\Persistence\ManagerRegistry; 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 public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{ {
if (!$user instanceof User) { if (!$user instanceof User) {
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\Schedule as SymfonySchedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;
/**
* Class Schedule
*
* @package App
* @author Lang <https://www.splendidbear.org>
* @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
;
}
}
+24
View File
@@ -316,6 +316,18 @@
"config/packages/mercure.yaml" "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": { "symfony/mime": {
"version": "v4.3.5" "version": "v4.3.5"
}, },
@@ -375,6 +387,18 @@
"ref": "cda8b550123383d25827705d05a42acf6819fe4e" "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": { "symfony/security": {
"version": "v4.0.9" "version": "v4.0.9"
}, },
@@ -0,0 +1,73 @@
<?php declare(strict_types=1);
/*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Command;
use App\Command\PurgeExpiredPendingUsersCommand;
use App\Entity\User;
use App\Tests\Factory\UserFactory;
use App\Tests\WebTestCase;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use Symfony\Component\Console\Tester\CommandTester;
#[TestDox('Purge Expired Pending Users Command')]
class PurgeExpiredPendingUsersCommandTest extends WebTestCase
{
#[Test]
#[TestDox('Deletes expired unverified users only')]
public function deletesOnlyExpiredUnverifiedUsers(): void
{
$expiredUser = UserFactory::createOne([
'isVerified' => 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));
}
}
@@ -10,6 +10,8 @@
namespace App\Tests\Controller; namespace App\Tests\Controller;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use App\Tests\Factory\UserFactory; use App\Tests\Factory\UserFactory;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\Attributes\TestDox;
@@ -103,6 +105,25 @@ class SecurityControllerTest extends WebTestCase
self::assertResponseRedirects('/login'); 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] #[Test]
#[TestDox('Password reset with invalid token redirects to forgot password')] #[TestDox('Password reset with invalid token redirects to forgot password')]
public function resetPasswordWithInvalidTokenShowsError(): void public function resetPasswordWithInvalidTokenShowsError(): void