diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 13c887f..bafe68f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -9,6 +9,9 @@ on:
permissions:
contents: read
+env:
+ XDEBUG_MODE: coverage
+
jobs:
build:
runs-on: ubuntu-latest
@@ -24,7 +27,8 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-versions }}
- # extensions: intl #optional
+ extensions: sqlite3, pdo_sqlite
+ coverage: xdebug
ini-values: "post_max_size=256M" #optional
- name: Check PHP Version
@@ -49,14 +53,23 @@ jobs:
run: ./vendor/bin/phpstan analyse
- name: Run tests
- run: ./vendor/bin/phpunit --coverage-text --coverage-html=.phpunit.cache/html-report
+ run: ./vendor/bin/phpunit
- name: Code Style check
run: ./vendor/bin/php-cs-fixer fix -v --diff --dry-run
- - name: phpunit-coverage-badge
- uses: timkrase/phpunit-coverage-badge@v1.2.1
+ - name: Check coverage report
+ id: coverage_report
+ run: |
+ if [ -f clover.xml ]; then
+ echo "has_clover=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "has_clover=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Upload coverage to Codecov
+ if: matrix.composer-prefer == '' && steps.coverage_report.outputs.has_clover == 'true'
+ uses: codecov/codecov-action@v5
with:
- push_badge: true
- coverage_badge_path: .github/badges/coverage.svg
- repo_token: ${{ secrets.GITHUB_TOKEN }}
+ token: ${{ secrets.CODECOV_TOKEN }}
+ files: clover.xml
diff --git a/.gitignore b/.gitignore
index 2cdcd0d..ddfdb0e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,4 +4,6 @@ composer.lock
.coverage-report/
.php-cs-fixer.cache
.php_cs.cache
-.phpunit.result.cache
\ No newline at end of file
+.phpunit.result.cache
+.phpunit.cache
+clover.xml
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..67992ad
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,11 @@
+# Contributing
+
+Use the standard package commands before sending changes:
+
+```bash
+composer fix
+composer test
+composer test-bc
+```
+
+[Report issues](https://github.com/softspring/user-bundle/issues) and [send Pull Requests](https://github.com/softspring/user-bundle/pulls)
diff --git a/FEATURES.md b/FEATURES.md
new file mode 100644
index 0000000..337a10f
--- /dev/null
+++ b/FEATURES.md
@@ -0,0 +1,54 @@
+# User Bundle Features
+
+Functional definition for `softspring/user-bundle`.
+
+## Purpose
+
+- Provide a reusable user layer for Symfony applications.
+- Cover common user lifecycle workflows without forcing a fixed application model.
+- Offer extension points so projects can adapt behavior and persistence.
+
+## Main Features
+
+- Base user model contracts and composable traits for:
+ - identifiers
+ - password support
+ - enabled status
+ - name and surname
+ - locale
+ - avatar
+ - admin/super-admin roles
+- User account workflows:
+ - login
+ - registration
+ - email confirmation
+ - password reset request and reset confirmation
+ - account settings update (email, username, password)
+- Administration workflows:
+ - users management
+ - administrators management
+ - invitations
+ - access history
+- Optional OAuth integration points.
+- Doctrine filters for user/admin visibility.
+- Mailers, MIME classes, events, and manipulators for user lifecycle actions.
+- Console commands for common user and admin operations.
+
+## Integration And Extension
+
+- Integrates with Symfony Security, Twig, Validator, Translation, and HTTP components.
+- Integrates with Doctrine ORM/Doctrine Bundle.
+- Integrates with Softspring packages such as:
+ - `permissions-bundle`
+ - `crudl-controller`
+ - `twig-extra-bundle`
+ - `doctrine-query-filters`
+ - `doctrine-target-entity-resolver`
+- Allows custom application entities by implementing bundle interfaces and composing provided traits.
+- Supports custom service overrides for controllers, managers, and mailers through Symfony configuration.
+
+## Expected Capabilities
+
+- Must work with the supported dependency matrix of this line, including Symfony `6.4`, `7.x`, and `8.x`.
+- Must keep both regular and lowest dependency validation workflows working (`composer test` and `composer test-bc`).
+- Must keep user-facing and admin-facing user workflows stable across minor releases in the same line.
diff --git a/README.md b/README.md
index 9a2c760..7b84e25 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,14 @@
# User Bundle
-
-
-
-
-
+[](https://github.com/softspring/user-bundle/releases)
+[](https://github.com/softspring/user-bundle/releases)
+[](https://github.com/softspring/user-bundle/blob/6.0/LICENSE)
+[](https://github.com/softspring/user-bundle/blob/6.0/composer.json)
+[](https://packagist.org/packages/softspring/user-bundle)
[](https://github.com/softspring/user-bundle/actions/workflows/ci.yml)
-
+[](https://codecov.io/gh/softspring/user-bundle)
-A complete user bundle
+A complete user bundle for Symfony applications, including user authentication flows, settings pages, invitations, and admin user management screens.
## Armonic
@@ -20,16 +20,14 @@ This package is part of [Armonic](https://softspring.es/en/armonic).
## Contributing
-Use the standard package commands before sending changes:
-
-```bash
-composer fix
-composer test
-composer test-bc
-```
+See [CONTRIBUTING.md](CONTRIBUTING.md).
[Report issues](https://github.com/softspring/user-bundle/issues) and [send Pull Requests](https://github.com/softspring/user-bundle/pulls)
+## Security
+
+See [SECURITY.md](SECURITY.md).
+
## License
This package is free and released under the [AGPL-3.0 license](LICENSE).
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..57f96fe
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,29 @@
+# Security Policy
+
+## Supported Versions
+
+Only the current stable line and the current development line receive security updates.
+
+| Version | Supported |
+| ------- | --------- |
+| 6.0.x | Yes |
+| 5.5.x | Yes |
+| < 5.5 | No |
+
+## Reporting a Vulnerability
+
+Please report security issues privately.
+
+Do not open public GitHub issues for security vulnerabilities.
+
+Send the report to: `security@softspring.es`
+
+Please include:
+
+- affected package
+- affected version
+- steps to reproduce
+- impact description
+- any suggested fix or mitigation if available
+
+We will review the report, confirm whether it is a valid vulnerability, and contact you with the next steps.
diff --git a/composer.json b/composer.json
index cb1dbd0..ac19d94 100644
--- a/composer.json
+++ b/composer.json
@@ -38,13 +38,14 @@
"softspring/doctrine-templates": "^6.0@dev",
"softspring/mailer-bundle": "^6.0@dev",
"softspring/media-bundle": "^6.0@dev",
+ "phpunit/phpunit": "^12.5",
"symfony/browser-kit": "^6.4 || ^7.4 || ^8.0",
"symfony/css-selector": "^6.4 || ^7.4 || ^8.0",
"symfony/dom-crawler": "^6.4 || ^7.4 || ^8.0",
"symfony/form": "^6.4 || ^7.4 || ^8.0",
"symfony/http-client": "^6.4 || ^7.4 || ^8.0",
"symfony/mailer": "^6.4 || ^7.4 || ^8.0",
- "symfony/phpunit-bridge": "^6.4 || ^7.4 || ^8.0"
+ "symfony/var-exporter": "^6.4 || ^7.4"
},
"suggest": {
"hwi/oauth-bundle": "Integrate oauth clients",
@@ -85,8 +86,8 @@
],
"run-tests": [
"composer validate --strict --no-check-lock --ansi",
- "# XDEBUG_MODE=coverage vendor/bin/phpunit",
- "vendor/bin/rector process src tests --dry-run",
+ "XDEBUG_MODE=coverage vendor/bin/phpunit",
+ "vendor/bin/rector process src tests --dry-run --debug",
"vendor/bin/phpstan analyse src tests --level 5",
"vendor/bin/php-cs-fixer fix --dry-run --diff",
"vendor/bin/yaml-lint .github/ -vvv --parse-tags",
diff --git a/config/services/mailer.yaml b/config/services/mailer.yaml
index f57f0be..d6b7fab 100644
--- a/config/services/mailer.yaml
+++ b/config/services/mailer.yaml
@@ -1,4 +1,9 @@
services:
+ _defaults:
+ autowire: true
+ autoconfigure: true
+ public: false
+
Softspring\UserBundle\Mailer\UserMailerInterface:
class: Softspring\UserBundle\Mailer\UserMailer
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 39688d9..5869fa7 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -2,13 +2,14 @@
-
+
@@ -21,16 +22,32 @@
- tests/
+ tests
tests/DataCollector/
tests/EventListener/
tests/Form/
-
+
src
+
+ vendor
+ tests
+
+
+
+
+
+
+
+
+
diff --git a/rector.php b/rector.php
index c7f9a17..37fd85e 100644
--- a/rector.php
+++ b/rector.php
@@ -5,7 +5,6 @@
use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\SetList;
use Rector\Symfony\Set\SymfonySetList;
-use Rector\Symfony\Symfony61\Rector\Class_\CommandConfigureToAttributeRector;
use Rector\ValueObject\PhpVersion;
return RectorConfig::configure()
@@ -23,5 +22,6 @@
->withPhpVersion(PhpVersion::PHP_84)
->withComposerBased(symfony: true)
->withSkip([
- CommandConfigureToAttributeRector::class,
+ __DIR__.'/tests/Unit/Security/Authorization/Voter/AdminAdministratorsActionsVoterTest.php',
+ __DIR__.'/tests/Unit/Security/Authorization/Voter/SwitchUserVoterTest.php',
]);
diff --git a/src/Command/CreateUserCommand.php b/src/Command/CreateUserCommand.php
index e0c33fd..af69b26 100644
--- a/src/Command/CreateUserCommand.php
+++ b/src/Command/CreateUserCommand.php
@@ -4,12 +4,14 @@
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Softspring\UserBundle\Manipulator\UserManipulator;
+use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
+#[AsCommand(name: 'sfs:user:create')]
class CreateUserCommand extends Command
{
protected UserManipulator $userManipulator;
@@ -23,7 +25,6 @@ public function __construct(UserManipulator $userManipulator, ?string $name = nu
protected function configure(): void
{
- $this->setName('sfs:user:create');
$this->addArgument('username', InputArgument::REQUIRED, 'Username');
$this->addArgument('email', InputArgument::REQUIRED, 'Email');
$this->addArgument('password', InputArgument::REQUIRED, 'Password');
diff --git a/src/Command/InviteUserCommand.php b/src/Command/InviteUserCommand.php
index 7988b4e..f8012b7 100644
--- a/src/Command/InviteUserCommand.php
+++ b/src/Command/InviteUserCommand.php
@@ -3,12 +3,14 @@
namespace Softspring\UserBundle\Command;
use Softspring\UserBundle\Manipulator\UserInvitationManipulator;
+use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
+#[AsCommand(name: 'sfs:user:invite')]
class InviteUserCommand extends Command
{
protected UserInvitationManipulator $userInvitationManipulator;
@@ -22,7 +24,6 @@ public function __construct(UserInvitationManipulator $userInvitationManipulator
protected function configure(): void
{
- $this->setName('sfs:user:invite');
$this->addArgument('email', InputArgument::REQUIRED, 'Email');
$this->addArgument('username', InputArgument::OPTIONAL, 'Username');
$this->addOption('role', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Add user roles (comma separated)', []);
diff --git a/src/Command/PromoteUserCommand.php b/src/Command/PromoteUserCommand.php
index 226fcea..c7fa0bd 100644
--- a/src/Command/PromoteUserCommand.php
+++ b/src/Command/PromoteUserCommand.php
@@ -5,12 +5,14 @@
use Softspring\UserBundle\Manager\UserManagerInterface;
use Softspring\UserBundle\Model\RolesAdminInterface;
use Softspring\UserBundle\Model\UserInterface;
+use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
+#[AsCommand(name: 'sfs:user:promote')]
class PromoteUserCommand extends Command
{
protected UserManagerInterface $userManager;
@@ -24,7 +26,6 @@ public function __construct(UserManagerInterface $userManager, ?string $name = n
protected function configure(): void
{
- $this->setName('sfs:user:promote');
$this->addArgument('identifier', InputArgument::REQUIRED, 'User identifier (username or email)');
$this->addOption('super-admin', 's', InputOption::VALUE_NONE, 'User is super admin');
}
diff --git a/src/DependencyInjection/SfsUserExtension.php b/src/DependencyInjection/SfsUserExtension.php
index 140a8e1..f0a35de 100644
--- a/src/DependencyInjection/SfsUserExtension.php
+++ b/src/DependencyInjection/SfsUserExtension.php
@@ -88,7 +88,7 @@ public function load(array $configs, ContainerBuilder $container): void
$loader->load('services/data_fixtures.yaml');
}
- if (class_exists(MailerInterface::class)) {
+ if (interface_exists(MailerInterface::class)) {
$loader->load('services/mailer.yaml');
}
}
diff --git a/src/Doctrine/Filter/AdminFilter.php b/src/Doctrine/Filter/AdminFilter.php
index 70f9e15..9e7f04c 100644
--- a/src/Doctrine/Filter/AdminFilter.php
+++ b/src/Doctrine/Filter/AdminFilter.php
@@ -8,7 +8,7 @@
class AdminFilter extends SQLFilter
{
- public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
+ public function addFilterConstraint(ClassMetadata $targetEntity, mixed $targetTableAlias): string
{
if (!$targetEntity->getReflectionClass()->implementsInterface(RolesAdminInterface::class)) {
return '';
diff --git a/src/Doctrine/Filter/UserFilter.php b/src/Doctrine/Filter/UserFilter.php
index c7ce34e..7418593 100644
--- a/src/Doctrine/Filter/UserFilter.php
+++ b/src/Doctrine/Filter/UserFilter.php
@@ -8,7 +8,7 @@
class UserFilter extends SQLFilter
{
- public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
+ public function addFilterConstraint(ClassMetadata $targetEntity, mixed $targetTableAlias): string
{
if (!$targetEntity->getReflectionClass()->implementsInterface(RolesAdminInterface::class)) {
return '';
diff --git a/src/Entity/RolesTrait.php b/src/Entity/RolesTrait.php
index 7cbab6a..6553279 100644
--- a/src/Entity/RolesTrait.php
+++ b/src/Entity/RolesTrait.php
@@ -9,6 +9,6 @@ trait RolesTrait
{
use RolesTraitModel;
- #[ORM\Column(name: 'roles', type: 'array', nullable: false)]
+ #[ORM\Column(name: 'roles', type: 'json', nullable: false)]
protected array $roles = [];
}
diff --git a/src/Mime/Example/Model/ExampleUser.php b/src/Mime/Example/Model/ExampleUser.php
index 6d727bf..3e38506 100644
--- a/src/Mime/Example/Model/ExampleUser.php
+++ b/src/Mime/Example/Model/ExampleUser.php
@@ -22,4 +22,8 @@ public function getDisplayName(): string
{
return $this->getName().' '.$this->getSurname();
}
+
+ public function eraseCredentials(): void
+ {
+ }
}
diff --git a/src/Model/ConfirmableTrait.php b/src/Model/ConfirmableTrait.php
index 6cc296a..46216f5 100644
--- a/src/Model/ConfirmableTrait.php
+++ b/src/Model/ConfirmableTrait.php
@@ -22,12 +22,12 @@ public function setConfirmationToken(?string $confirmationToken): void
public function getConfirmedAt(): ?DateTime
{
- return DateTime::createFromFormat('U', $this->confirmedAt) ?: null;
+ return $this->confirmedAt ? DateTime::createFromFormat('U', (string) $this->confirmedAt) : null;
}
public function setConfirmedAt(?DateTime $confirmedAt): void
{
- $this->confirmedAt = $confirmedAt instanceof DateTime ? $confirmedAt->format('U') : null;
+ $this->confirmedAt = $confirmedAt instanceof DateTime ? (int) $confirmedAt->format('U') : null;
}
public function isConfirmed(): bool
diff --git a/src/Security/Authorization/Voter/AdminAdministratorsActionsVoter.php b/src/Security/Authorization/Voter/AdminAdministratorsActionsVoter.php
index 142f0e4..afe76ba 100644
--- a/src/Security/Authorization/Voter/AdminAdministratorsActionsVoter.php
+++ b/src/Security/Authorization/Voter/AdminAdministratorsActionsVoter.php
@@ -4,7 +4,6 @@
use Softspring\UserBundle\Model\RolesAdminInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
-use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Security\Core\Exception\InvalidArgumentException;
@@ -19,7 +18,7 @@ public function supportsObject(mixed $administrator): bool
return $administrator instanceof RolesAdminInterface;
}
- public function vote(TokenInterface $token, $subject, array $attributes, ?Vote $vote = null): int
+ public function vote(TokenInterface $token, $subject, array $attributes, mixed $vote = null): int
{
/** @var RolesAdminInterface $administrator */
$administrator = $subject;
diff --git a/src/Security/Authorization/Voter/SwitchUserVoter.php b/src/Security/Authorization/Voter/SwitchUserVoter.php
index fdb37b9..aa9e756 100644
--- a/src/Security/Authorization/Voter/SwitchUserVoter.php
+++ b/src/Security/Authorization/Voter/SwitchUserVoter.php
@@ -5,7 +5,6 @@
use Softspring\UserBundle\Model\RolesAdminInterface;
use Softspring\UserBundle\Model\UserInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
-use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Security\Core\Exception\InvalidArgumentException;
@@ -20,7 +19,7 @@ public function supportsObject(mixed $user): bool
return $user instanceof UserInterface && $user instanceof RolesAdminInterface;
}
- public function vote(TokenInterface $token, $subject, array $attributes, ?Vote $vote = null): int
+ public function vote(TokenInterface $token, $subject, array $attributes, mixed $vote = null): int
{
/** @var UserInterface|RolesAdminInterface $user */
$user = $subject;
diff --git a/tests/Functional/AbstractWebTestCase.php b/tests/Functional/AbstractWebTestCase.php
new file mode 100644
index 0000000..27b902c
--- /dev/null
+++ b/tests/Functional/AbstractWebTestCase.php
@@ -0,0 +1,56 @@
+markTestSkipped('pdo_sqlite is required to run functional tests in the test application.');
+ }
+ }
+
+ protected function getEntityManager(): EntityManagerInterface
+ {
+ return self::getContainer()->get(EntityManagerInterface::class);
+ }
+
+ protected function getUserManager(): UserManagerInterface
+ {
+ return self::getContainer()->get(UserManagerInterface::class);
+ }
+
+ protected function getUserRepository(): object
+ {
+ return $this->getEntityManager()->getRepository(User::class);
+ }
+
+ protected function findUserByEmail(string $email): ?User
+ {
+ $user = $this->getUserRepository()->findOneBy(['email' => $email]);
+
+ return $user instanceof User ? $user : null;
+ }
+
+ protected function createPersistedUser(bool $admin = false, bool $superAdmin = false): User
+ {
+ $user = new User();
+ $user->setName($admin ? 'Admin' : 'Functional');
+ $user->setSurname($superAdmin ? 'Super' : 'User');
+ $user->setEmail(sprintf('functional-%s@example.com', uniqid()));
+ $user->setPlainPassword('123456');
+ $user->setLocale('en');
+ $user->setAdmin($admin);
+ $user->setSuperAdmin($superAdmin);
+
+ $this->getUserManager()->saveEntity($user);
+
+ return $user;
+ }
+}
diff --git a/tests/Functional/AdminFlowsTest.php b/tests/Functional/AdminFlowsTest.php
new file mode 100644
index 0000000..0d7a807
--- /dev/null
+++ b/tests/Functional/AdminFlowsTest.php
@@ -0,0 +1,56 @@
+request('GET', '/app/user/preferences/');
+
+ self::assertResponseRedirects('/app/login');
+ }
+
+ public function testPreferencesPageRendersForAuthenticatedUser(): void
+ {
+ $client = static::createClient();
+ $user = $this->findUserByEmail('user@example.com');
+
+ self::assertNotNull($user);
+
+ $client->loginUser($user, 'main');
+ $client->request('GET', '/app/user/preferences/');
+
+ self::assertResponseIsSuccessful();
+ self::assertSelectorTextContains('h1', 'Preferences');
+ }
+
+ public function testAdminUsersListRendersForAdministrator(): void
+ {
+ $client = static::createClient();
+ $administrator = $this->findUserByEmail('admin@example.com');
+
+ self::assertNotNull($administrator);
+
+ $client->loginUser($administrator, 'main');
+ $client->request('GET', '/admin/users/user/');
+
+ self::assertResponseIsSuccessful();
+ self::assertStringContainsString('user@example.com', $client->getResponse()->getContent());
+ }
+
+ public function testAdminAdministratorsListRendersForAdministrator(): void
+ {
+ $client = static::createClient();
+ $administrator = $this->findUserByEmail('admin@example.com');
+
+ self::assertNotNull($administrator);
+
+ $client->loginUser($administrator, 'main');
+ $client->request('GET', '/admin/users/admins/');
+
+ self::assertResponseIsSuccessful();
+ self::assertStringContainsString('admin@example.com', $client->getResponse()->getContent());
+ }
+}
diff --git a/tests/Functional/PublicFlowsTest.php b/tests/Functional/PublicFlowsTest.php
new file mode 100644
index 0000000..7395aa3
--- /dev/null
+++ b/tests/Functional/PublicFlowsTest.php
@@ -0,0 +1,130 @@
+request('GET', '/app/login');
+
+ self::assertResponseIsSuccessful();
+ self::assertSelectorExists('form input[name="_username"]');
+ self::assertSelectorExists('form input[name="_password"]');
+ }
+
+ public function testLoginWithFixtureUserRedirectsToPreferences(): void
+ {
+ $client = static::createClient();
+ $crawler = $client->request('GET', '/app/login');
+
+ $client->submit($crawler->filter('form')->form([
+ '_username' => 'user@example.com',
+ '_password' => '123456',
+ ]));
+
+ self::assertResponseRedirects('/app/user/preferences/');
+
+ $client->followRedirect();
+
+ self::assertResponseIsSuccessful();
+ self::assertSelectorTextContains('h1', 'Preferences');
+ }
+
+ public function testRegisterCreatesUserAndAuthenticatesTheNewAccount(): void
+ {
+ $client = static::createClient();
+ $email = sprintf('register-%s@example.com', uniqid());
+ $crawler = $client->request('GET', '/app/register/');
+
+ $client->submit($crawler->filter('form')->form([
+ 'register_form[name]' => 'New',
+ 'register_form[surname]' => 'User',
+ 'register_form[email]' => $email,
+ 'register_form[plainPassword]' => 'new-password',
+ 'register_form[acceptConditions]' => '1',
+ ]));
+
+ self::assertResponseRedirects('/app/register/welcome');
+
+ $client->followRedirect();
+
+ self::assertResponseIsSuccessful();
+ self::assertStringContainsString('Welcome New', $client->getResponse()->getContent());
+
+ $user = $this->findUserByEmail($email);
+
+ self::assertNotNull($user);
+ self::assertNotSame('new-password', $user->getPassword());
+ self::assertNotNull($user->getConfirmationToken());
+ }
+
+ public function testResetPasswordRequestStoresTokenForExistingUser(): void
+ {
+ $client = static::createClient();
+ $crawler = $client->request('GET', '/app/reset-password/');
+
+ $client->submit($crawler->filter('form')->form([
+ 'reset_password_request_form[email]' => 'user@example.com',
+ ]));
+
+ self::assertResponseRedirects('/app/reset-password/sent');
+
+ $client->followRedirect();
+
+ self::assertResponseIsSuccessful();
+ self::assertStringContainsString('user@example.com', $client->getResponse()->getContent());
+
+ $user = $this->findUserByEmail('user@example.com');
+
+ self::assertNotNull($user);
+ self::assertNotNull($user->getPasswordRequestToken());
+ self::assertNotNull($user->getPasswordRequestedAt());
+ }
+
+ public function testResetPasswordUpdatesStoredPasswordAndClearsToken(): void
+ {
+ $client = static::createClient();
+ $user = $this->createPersistedUser();
+ $crawler = $client->request('GET', '/app/reset-password/');
+
+ $client->submit($crawler->filter('form')->form([
+ 'reset_password_request_form[email]' => $user->getEmail(),
+ ]));
+
+ $this->getEntityManager()->clear();
+ $user = $this->findUserByEmail($user->getEmail());
+
+ self::assertNotNull($user);
+ self::assertNotNull($user->getPasswordRequestToken());
+
+ $resetCrawler = $client->request('GET', sprintf('/app/reset-password/%s/reset/%s', $user->getId(), $user->getPasswordRequestToken()));
+
+ $client->submit($resetCrawler->filter('form')->form([
+ 'reset_password_form[plainPassword][first]' => 'updated-password',
+ 'reset_password_form[plainPassword][second]' => 'updated-password',
+ ]));
+
+ self::assertResponseRedirects('/app/reset-password/success');
+
+ $client->followRedirect();
+
+ self::assertResponseIsSuccessful();
+
+ $this->getEntityManager()->clear();
+ $reloadedUser = $this->findUserByEmail($user->getEmail());
+
+ self::assertNotNull($reloadedUser);
+ self::assertNull($reloadedUser->getPasswordRequestToken());
+ self::assertNull($reloadedUser->getPasswordRequestedAt());
+
+ $loginCrawler = $client->request('GET', '/app/login');
+ $client->submit($loginCrawler->filter('form')->form([
+ '_username' => $reloadedUser->getEmail(),
+ '_password' => 'updated-password',
+ ]));
+
+ self::assertResponseRedirects('/app/user/preferences/');
+ }
+}
diff --git a/tests/TestApplication/config/packages/doctrine.yaml b/tests/TestApplication/config/packages/doctrine.yaml
index 5e59f11..c643e63 100644
--- a/tests/TestApplication/config/packages/doctrine.yaml
+++ b/tests/TestApplication/config/packages/doctrine.yaml
@@ -9,7 +9,7 @@ doctrine:
mappings:
TestEntities:
is_bundle: false
- type: 'annotation'
+ type: 'attribute'
dir: '%kernel.project_dir%/src/Entity'
prefix: 'Softspring\UserBundle\Tests\TestApplication\Entity'
- alias: 'app'
\ No newline at end of file
+ alias: 'app'
diff --git a/tests/TestApplication/config/packages/framework.yaml b/tests/TestApplication/config/packages/framework.yaml
index d42b6ef..7a35840 100644
--- a/tests/TestApplication/config/packages/framework.yaml
+++ b/tests/TestApplication/config/packages/framework.yaml
@@ -1,5 +1,6 @@
framework:
secret: 'TEST'
+ enabled_locales: ['en', 'es']
csrf_protection: true
session:
handler_id: null
diff --git a/tests/TestApplication/config/packages/security.yaml b/tests/TestApplication/config/packages/security.yaml
index 4907bea..86c29df 100644
--- a/tests/TestApplication/config/packages/security.yaml
+++ b/tests/TestApplication/config/packages/security.yaml
@@ -1,29 +1,38 @@
-security:
- enable_authenticator_manager: true
+imports:
+ - { resource: '@SfsUserBundle/config/security/admin_role_hierarchy.yaml' }
+security:
password_hashers:
- Symfony\Component\Security\Core\User\InMemoryUser: plaintext
+ Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: auto
providers:
- test_users:
- memory:
- users:
- user:
- password: 123456
- roles: ['ROLE_USER']
- admin:
- password: 123456
- roles: ['ROLE_ADMIN']
+ sfs_user:
+ id: sfs_user.user_provider
+
+ access_decision_manager:
+ strategy: unanimous
+ allow_if_all_abstain: false
firewalls:
- secure_admin:
- pattern: '^/secure_admin'
- provider: 'test_users'
- http_basic: null
- logout: null
+ main:
+ lazy: true
+ provider: sfs_user
+ form_login:
+ provider: sfs_user
+ login_path: sfs_user_login
+ check_path: sfs_user_login_check
+ default_target_path: sfs_user_preferences
+ logout:
+ path: sfs_user_logout
role_hierarchy:
- ROLE_ADMIN: ['ROLE_USER']
+ ROLE_ADMIN:
+ - ROLE_SFS_USER_ADMIN_USERS_RW
+ - ROLE_SFS_USER_ADMIN_ADMINISTRATORS_RW
access_control:
- - { path: '^/secure_admin', roles: ['ROLE_USER'] }
+ - { path: '^/app/login', roles: PUBLIC_ACCESS }
+ - { path: '^/app/register', roles: PUBLIC_ACCESS }
+ - { path: '^/app/reset-password', roles: PUBLIC_ACCESS }
+ - { path: '^/admin', roles: ROLE_ADMIN }
+ - { path: '^/app', roles: ROLE_USER }
diff --git a/tests/TestApplication/config/packages/sfs_user.yaml b/tests/TestApplication/config/packages/sfs_user.yaml
index 190221d..aa94c39 100644
--- a/tests/TestApplication/config/packages/sfs_user.yaml
+++ b/tests/TestApplication/config/packages/sfs_user.yaml
@@ -1,2 +1,10 @@
sfs_user:
- class: Softspring\UserBundle\Tests\TestApplication\Entity\User
\ No newline at end of file
+ class: Softspring\UserBundle\Tests\TestApplication\Entity\User
+ login:
+ target_path_parameter: _target_path
+
+ invite:
+ enabled: false
+
+ history:
+ enabled: false
diff --git a/tests/TestApplication/config/packages/twig.yaml b/tests/TestApplication/config/packages/twig.yaml
index 20b13f6..1d7294c 100644
--- a/tests/TestApplication/config/packages/twig.yaml
+++ b/tests/TestApplication/config/packages/twig.yaml
@@ -1,2 +1,4 @@
twig:
- default_path: '%kernel.project_dir%/templates'
\ No newline at end of file
+ default_path: '%kernel.project_dir%/templates'
+ paths:
+ '%kernel.project_dir%/templates/sfs_components': SfsComponents
diff --git a/tests/TestApplication/config/routes.yaml b/tests/TestApplication/config/routes.yaml
index e69de29..58cccab 100644
--- a/tests/TestApplication/config/routes.yaml
+++ b/tests/TestApplication/config/routes.yaml
@@ -0,0 +1,27 @@
+_sfs_user:
+ resource: '@SfsUserBundle/config/routing/login.yaml'
+ prefix: /app
+
+_sfs_user_register:
+ resource: '@SfsUserBundle/config/routing/register.yaml'
+ prefix: /app/register
+
+_sfs_user_reset_password:
+ resource: '@SfsUserBundle/config/routing/reset_password.yaml'
+ prefix: /app/reset-password
+
+_sfs_user_preferences:
+ resource: '@SfsUserBundle/config/routing/preferences.yaml'
+ prefix: /app/user/preferences
+
+_sfs_user_change_password:
+ resource: '@SfsUserBundle/config/routing/change_password.yaml'
+ prefix: /app/user/change-password
+
+_sfs_user_admin:
+ resource: '@SfsUserBundle/config/routing/admin_users.yaml'
+ prefix: /admin/users/user
+
+_sfs_user_admins:
+ resource: '@SfsUserBundle/config/routing/admin_administrators.yaml'
+ prefix: /admin/users/admins
diff --git a/tests/TestApplication/config/services.yaml b/tests/TestApplication/config/services.yaml
index 568ec15..8de9745 100644
--- a/tests/TestApplication/config/services.yaml
+++ b/tests/TestApplication/config/services.yaml
@@ -10,9 +10,10 @@ services:
resource: '../src/*'
exclude: '../src/{Entity,Tests,Kernel.php}'
- Softspring\UserBundle\Tests\TestApplication\Controller\:
- resource: '../src/Controller'
- tags: ['controller.service_arguments']
-
Softspring\UserBundle\Tests\TestApplication\DataFixtures\AppFixtures:
- tags: ['doctrine.fixture.orm']
\ No newline at end of file
+ tags: ['doctrine.fixture.orm']
+
+ Softspring\UserBundle\Tests\TestApplication\TestUserMailer: ~
+
+ Softspring\UserBundle\Mailer\UserMailerInterface:
+ alias: Softspring\UserBundle\Tests\TestApplication\TestUserMailer
diff --git a/tests/TestApplication/src/DataFixtures/AppFixtures.php b/tests/TestApplication/src/DataFixtures/AppFixtures.php
index 5b4c8bf..41e0771 100644
--- a/tests/TestApplication/src/DataFixtures/AppFixtures.php
+++ b/tests/TestApplication/src/DataFixtures/AppFixtures.php
@@ -1,13 +1,42 @@
setName('Regular');
+ $user->setSurname('User');
+ $user->setEmail('user@example.com');
+ $user->setPlainPassword('123456');
+ $user->setLocale('en');
+ $this->userManager->saveEntity($user);
+
+ $admin = new User();
+ $admin->setName('Admin');
+ $admin->setSurname('User');
+ $admin->setEmail('admin@example.com');
+ $admin->setPlainPassword('123456');
+ $admin->setAdmin(true);
+ $admin->setLocale('en');
+ $this->userManager->saveEntity($admin);
+ }
+
+ public static function getGroups(): array
+ {
+ return ['test_application'];
}
-}
\ No newline at end of file
+}
diff --git a/tests/TestApplication/src/Entity/User.php b/tests/TestApplication/src/Entity/User.php
index 002a8b7..b543800 100644
--- a/tests/TestApplication/src/Entity/User.php
+++ b/tests/TestApplication/src/Entity/User.php
@@ -3,31 +3,37 @@
namespace Softspring\UserBundle\Tests\TestApplication\Entity;
use Doctrine\ORM\Mapping as ORM;
+use Softspring\UserBundle\Entity\ConfirmableTrait;
use Softspring\UserBundle\Entity\NameSurnameTrait;
use Softspring\UserBundle\Entity\PasswordRequestTrait;
use Softspring\UserBundle\Entity\RolesFullTrait;
use Softspring\UserBundle\Entity\UserHasLocalePreferenceTrait;
use Softspring\UserBundle\Entity\UserIdentifierEmailTrait;
use Softspring\UserBundle\Entity\UserLastLoginTrait;
+use Softspring\UserBundle\Entity\UserPasswordTrait;
+use Softspring\UserBundle\Model\ConfirmableInterface;
use Softspring\UserBundle\Model\NameSurnameInterface;
use Softspring\UserBundle\Model\PasswordRequestInterface;
use Softspring\UserBundle\Model\RolesFullInterface;
use Softspring\UserBundle\Model\User as UserModel;
use Softspring\UserBundle\Model\UserHasLocalePreferenceInterface;
-use Softspring\UserBundle\Model\UserWithEmailInterface;
+use Softspring\UserBundle\Model\UserIdentifierEmailInterface;
+use Softspring\UserBundle\Model\UserPasswordInterface;
/**
* @ORM\Entity
*/
#[ORM\Entity]
-class User extends UserModel implements NameSurnameInterface, PasswordRequestInterface, UserWithEmailInterface, UserHasLocalePreferenceInterface, RolesFullInterface
+class User extends UserModel implements NameSurnameInterface, PasswordRequestInterface, UserIdentifierEmailInterface, UserHasLocalePreferenceInterface, RolesFullInterface, UserPasswordInterface, ConfirmableInterface
{
+ use ConfirmableTrait;
use NameSurnameTrait;
use PasswordRequestTrait;
use UserIdentifierEmailTrait;
use UserHasLocalePreferenceTrait;
use RolesFullTrait;
use UserLastLoginTrait;
+ use UserPasswordTrait;
/**
* @ORM\Column(type="string", nullable=false, length=32)
@@ -55,8 +61,7 @@ public function getDisplayName(): string
return $this->getName();
}
- public function eraseCredentials()
+ public function eraseCredentials(): void
{
-
}
-}
\ No newline at end of file
+}
diff --git a/tests/TestApplication/src/Kernel.php b/tests/TestApplication/src/Kernel.php
index 66f6640..947d6f2 100644
--- a/tests/TestApplication/src/Kernel.php
+++ b/tests/TestApplication/src/Kernel.php
@@ -4,6 +4,8 @@
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
+use Softspring\PermissionsBundle\SfsPermissionsBundle;
+use Softspring\TwigExtraBundle\SfsTwigExtraBundle;
use Softspring\UserBundle\SfsUserBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
@@ -33,6 +35,8 @@ public function registerBundles(): iterable
new DoctrineBundle(),
new DoctrineFixturesBundle(),
new SecurityBundle(),
+ new SfsTwigExtraBundle(),
+ new SfsPermissionsBundle(),
new SfsUserBundle(),
];
}
@@ -73,4 +77,4 @@ protected function configureContainer(ContainerBuilder $containerBuilder, Loader
$loader->load($confDir.'/{services}'.self::CONFIG_EXTS, 'glob');
$loader->load($confDir.'/{services}_'.$this->environment.self::CONFIG_EXTS, 'glob');
}
-}
\ No newline at end of file
+}
diff --git a/tests/TestApplication/src/TestUserMailer.php b/tests/TestApplication/src/TestUserMailer.php
new file mode 100644
index 0000000..69e19d3
--- /dev/null
+++ b/tests/TestApplication/src/TestUserMailer.php
@@ -0,0 +1,22 @@
+
+ {% block content %}{% endblock %}
+
+{% endblock %}
diff --git a/tests/TestApplication/templates/base.html.twig b/tests/TestApplication/templates/base.html.twig
new file mode 100644
index 0000000..2aa0c04
--- /dev/null
+++ b/tests/TestApplication/templates/base.html.twig
@@ -0,0 +1,10 @@
+
+
+
+
+ {% block title %}Test Application{% endblock %}
+
+
+{% block body %}{% endblock %}
+
+
diff --git a/tests/TestApplication/templates/sfs_components/forms/admin-horizontal.html.twig b/tests/TestApplication/templates/sfs_components/forms/admin-horizontal.html.twig
new file mode 100644
index 0000000..82d60d8
--- /dev/null
+++ b/tests/TestApplication/templates/sfs_components/forms/admin-horizontal.html.twig
@@ -0,0 +1 @@
+{# Minimal form theme placeholder for admin bundle templates in tests. #}
diff --git a/tests/TestApplication/templates/sfs_components/paginator/table.html.twig b/tests/TestApplication/templates/sfs_components/paginator/table.html.twig
new file mode 100644
index 0000000..4506b29
--- /dev/null
+++ b/tests/TestApplication/templates/sfs_components/paginator/table.html.twig
@@ -0,0 +1,6 @@
+
+
+ {% block ths %}{% endblock %}
+
+ {% block tbody %}{% endblock %}
+
diff --git a/tests/Unit/Doctrine/Filter/AdminFilterTest.php b/tests/Unit/Doctrine/Filter/AdminFilterTest.php
new file mode 100644
index 0000000..5d828fe
--- /dev/null
+++ b/tests/Unit/Doctrine/Filter/AdminFilterTest.php
@@ -0,0 +1,51 @@
+newInstanceWithoutConstructor();
+ $metadata = $this->createMetadata(User::class);
+
+ $constraint = $filter->addFilterConstraint($metadata, 'user_alias');
+
+ self::assertSame('user_alias.is_admin = 1', $constraint);
+ }
+
+ public function testReturnsEmptyConstraintForUnsupportedEntities(): void
+ {
+ $filter = (new ReflectionClass(AdminFilter::class))->newInstanceWithoutConstructor();
+ $metadata = $this->createMetadata(stdClass::class);
+
+ $constraint = $filter->addFilterConstraint($metadata, 'user_alias');
+
+ self::assertSame('', $constraint);
+ }
+
+ private function createMetadata(string $class): ClassMetadata
+ {
+ return new class($class) extends ClassMetadata {
+ private ReflectionClass $reflectionClass;
+
+ public function __construct(string $class)
+ {
+ parent::__construct($class);
+ $this->reflectionClass = new ReflectionClass($class);
+ }
+
+ public function getReflectionClass(): ReflectionClass
+ {
+ return $this->reflectionClass;
+ }
+ };
+ }
+}
diff --git a/tests/Unit/Doctrine/Filter/UserFilterTest.php b/tests/Unit/Doctrine/Filter/UserFilterTest.php
new file mode 100644
index 0000000..88ee4f1
--- /dev/null
+++ b/tests/Unit/Doctrine/Filter/UserFilterTest.php
@@ -0,0 +1,51 @@
+newInstanceWithoutConstructor();
+ $metadata = $this->createMetadata(User::class);
+
+ $constraint = $filter->addFilterConstraint($metadata, 'user_alias');
+
+ self::assertSame('user_alias.is_admin = 0', $constraint);
+ }
+
+ public function testReturnsEmptyConstraintForUnsupportedEntities(): void
+ {
+ $filter = (new ReflectionClass(UserFilter::class))->newInstanceWithoutConstructor();
+ $metadata = $this->createMetadata(stdClass::class);
+
+ $constraint = $filter->addFilterConstraint($metadata, 'user_alias');
+
+ self::assertSame('', $constraint);
+ }
+
+ private function createMetadata(string $class): ClassMetadata
+ {
+ return new class($class) extends ClassMetadata {
+ private ReflectionClass $reflectionClass;
+
+ public function __construct(string $class)
+ {
+ parent::__construct($class);
+ $this->reflectionClass = new ReflectionClass($class);
+ }
+
+ public function getReflectionClass(): ReflectionClass
+ {
+ return $this->reflectionClass;
+ }
+ };
+ }
+}
diff --git a/tests/Unit/Security/Authorization/Voter/AdminAdministratorsActionsVoterTest.php b/tests/Unit/Security/Authorization/Voter/AdminAdministratorsActionsVoterTest.php
new file mode 100644
index 0000000..2a89554
--- /dev/null
+++ b/tests/Unit/Security/Authorization/Voter/AdminAdministratorsActionsVoterTest.php
@@ -0,0 +1,178 @@
+createAdministrator();
+ $administrator = $this->createAdministrator();
+
+ $vote = $voter->vote($this->createToken($currentUser), $administrator, ['ROLE_ADMIN']);
+
+ self::assertSame(VoterInterface::ACCESS_ABSTAIN, $vote);
+ }
+
+ public function testDeniesWhenCurrentUserIsNotAdministrator(): void
+ {
+ $voter = new AdminAdministratorsActionsVoter();
+ $currentUser = $this->createUser();
+ $administrator = $this->createAdministrator();
+
+ $vote = $voter->vote($this->createToken($currentUser), $administrator, ['PERMISSION_SFS_USER_ADMIN_ADMINISTRATORS_UPDATE']);
+
+ self::assertSame(VoterInterface::ACCESS_DENIED, $vote);
+ }
+
+ public function testDeniesNonSuperAdminAgainstSuperAdmin(): void
+ {
+ $voter = new AdminAdministratorsActionsVoter();
+ $currentUser = $this->createAdministrator();
+ $administrator = $this->createAdministrator(superAdmin: true);
+
+ $vote = $voter->vote($this->createToken($currentUser), $administrator, ['PERMISSION_SFS_USER_ADMIN_ADMINISTRATORS_UPDATE']);
+
+ self::assertSame(VoterInterface::ACCESS_DENIED, $vote);
+ }
+
+ public function testDeniesDangerousActionsOnOwnAdministrator(): void
+ {
+ $voter = new AdminAdministratorsActionsVoter();
+ $administrator = $this->createAdministrator(superAdmin: true);
+
+ $vote = $voter->vote($this->createToken($administrator), $administrator, ['PERMISSION_SFS_USER_ADMIN_ADMINISTRATORS_DELETE']);
+
+ self::assertSame(VoterInterface::ACCESS_DENIED, $vote);
+ }
+
+ public function testGrantsSafeActionsOnOwnAdministrator(): void
+ {
+ $voter = new AdminAdministratorsActionsVoter();
+ $administrator = $this->createAdministrator(superAdmin: true);
+
+ $vote = $voter->vote($this->createToken($administrator), $administrator, ['PERMISSION_SFS_USER_ADMIN_ADMINISTRATORS_UPDATE']);
+
+ self::assertSame(VoterInterface::ACCESS_GRANTED, $vote);
+ }
+
+ public function testThrowsForUnsupportedCurrentUserClass(): void
+ {
+ $voter = new AdminAdministratorsActionsVoter();
+ $administrator = $this->createAdministrator();
+
+ $this->expectException(InvalidArgumentException::class);
+ $voter->vote($this->createToken(new class implements SymfonyUserInterface {
+ public function getRoles(): array
+ {
+ return [];
+ }
+
+ public function eraseCredentials(): void
+ {
+ }
+
+ public function getUserIdentifier(): string
+ {
+ return 'unsupported-user';
+ }
+ }), $administrator, ['PERMISSION_SFS_USER_ADMIN_ADMINISTRATORS_UPDATE']);
+ }
+
+ private function createToken(mixed $user): TokenInterface
+ {
+ return new class($user) implements TokenInterface {
+ public function __construct(private readonly ?SymfonyUserInterface $user)
+ {
+ }
+
+ public function __toString(): string
+ {
+ return 'test-token';
+ }
+
+ public function getUserIdentifier(): string
+ {
+ return $this->user?->getUserIdentifier() ?? '';
+ }
+
+ public function getRoleNames(): array
+ {
+ return $this->user?->getRoles() ?? [];
+ }
+
+ public function getUser(): ?SymfonyUserInterface
+ {
+ return $this->user;
+ }
+
+ public function setUser(SymfonyUserInterface $user): void
+ {
+ throw new BadMethodCallException('Not implemented for tests.');
+ }
+
+ public function eraseCredentials(): void
+ {
+ }
+
+ public function getAttributes(): array
+ {
+ return [];
+ }
+
+ public function setAttributes(array $attributes): void
+ {
+ }
+
+ public function hasAttribute(string $name): bool
+ {
+ return false;
+ }
+
+ public function getAttribute(string $name): mixed
+ {
+ throw new \InvalidArgumentException(sprintf('Attribute "%s" not available.', $name));
+ }
+
+ public function setAttribute(string $name, mixed $value): void
+ {
+ }
+
+ public function __serialize(): array
+ {
+ return [];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+ };
+ }
+
+ private function createUser(): User
+ {
+ $user = new User();
+ $user->setEmail(sprintf('user-%s@example.com', uniqid()));
+
+ return $user;
+ }
+
+ private function createAdministrator(bool $superAdmin = false): User
+ {
+ $user = $this->createUser();
+ $user->setAdmin(true);
+ $user->setSuperAdmin($superAdmin);
+
+ return $user;
+ }
+}
diff --git a/tests/Unit/Security/Authorization/Voter/SwitchUserVoterTest.php b/tests/Unit/Security/Authorization/Voter/SwitchUserVoterTest.php
new file mode 100644
index 0000000..8e36cb0
--- /dev/null
+++ b/tests/Unit/Security/Authorization/Voter/SwitchUserVoterTest.php
@@ -0,0 +1,169 @@
+vote($this->createToken($this->createAdministrator()), $this->createUser(), ['ROLE_USER']);
+
+ self::assertSame(VoterInterface::ACCESS_ABSTAIN, $vote);
+ }
+
+ public function testDeniesSwitchingToSuperAdmin(): void
+ {
+ $voter = new SwitchUserVoter();
+
+ $vote = $voter->vote($this->createToken($this->createAdministrator(superAdmin: true)), $this->createAdministrator(superAdmin: true), ['ROLE_ALLOWED_TO_SWITCH']);
+
+ self::assertSame(VoterInterface::ACCESS_DENIED, $vote);
+ }
+
+ public function testDeniesSwitchingToAdministratorForNonSuperAdmin(): void
+ {
+ $voter = new SwitchUserVoter();
+
+ $vote = $voter->vote($this->createToken($this->createAdministrator()), $this->createAdministrator(), ['ROLE_ALLOWED_TO_SWITCH']);
+
+ self::assertSame(VoterInterface::ACCESS_DENIED, $vote);
+ }
+
+ public function testGrantsSwitchingToRegularUser(): void
+ {
+ $voter = new SwitchUserVoter();
+
+ $vote = $voter->vote($this->createToken($this->createAdministrator()), $this->createUser(), ['ROLE_ALLOWED_TO_SWITCH']);
+
+ self::assertSame(VoterInterface::ACCESS_GRANTED, $vote);
+ }
+
+ public function testGrantsSwitchingToAdministratorForSuperAdmin(): void
+ {
+ $voter = new SwitchUserVoter();
+
+ $vote = $voter->vote($this->createToken($this->createAdministrator(superAdmin: true)), $this->createAdministrator(), ['ROLE_ALLOWED_TO_SWITCH']);
+
+ self::assertSame(VoterInterface::ACCESS_GRANTED, $vote);
+ }
+
+ public function testThrowsForUnsupportedCurrentUserClass(): void
+ {
+ $voter = new SwitchUserVoter();
+
+ $this->expectException(InvalidArgumentException::class);
+ $voter->vote($this->createToken(new class implements SymfonyUserInterface {
+ public function getRoles(): array
+ {
+ return [];
+ }
+
+ public function eraseCredentials(): void
+ {
+ }
+
+ public function getUserIdentifier(): string
+ {
+ return 'unsupported-user';
+ }
+ }), $this->createUser(), ['ROLE_ALLOWED_TO_SWITCH']);
+ }
+
+ private function createToken(mixed $user): TokenInterface
+ {
+ return new class($user) implements TokenInterface {
+ public function __construct(private readonly ?SymfonyUserInterface $user)
+ {
+ }
+
+ public function __toString(): string
+ {
+ return 'test-token';
+ }
+
+ public function getUserIdentifier(): string
+ {
+ return $this->user?->getUserIdentifier() ?? '';
+ }
+
+ public function getRoleNames(): array
+ {
+ return $this->user?->getRoles() ?? [];
+ }
+
+ public function getUser(): ?SymfonyUserInterface
+ {
+ return $this->user;
+ }
+
+ public function setUser(SymfonyUserInterface $user): void
+ {
+ throw new BadMethodCallException('Not implemented for tests.');
+ }
+
+ public function eraseCredentials(): void
+ {
+ }
+
+ public function getAttributes(): array
+ {
+ return [];
+ }
+
+ public function setAttributes(array $attributes): void
+ {
+ }
+
+ public function hasAttribute(string $name): bool
+ {
+ return false;
+ }
+
+ public function getAttribute(string $name): mixed
+ {
+ throw new \InvalidArgumentException(sprintf('Attribute "%s" not available.', $name));
+ }
+
+ public function setAttribute(string $name, mixed $value): void
+ {
+ }
+
+ public function __serialize(): array
+ {
+ return [];
+ }
+
+ public function __unserialize(array $data): void
+ {
+ }
+ };
+ }
+
+ private function createUser(): User
+ {
+ $user = new User();
+ $user->setEmail(sprintf('user-%s@example.com', uniqid()));
+
+ return $user;
+ }
+
+ private function createAdministrator(bool $superAdmin = false): User
+ {
+ $user = $this->createUser();
+ $user->setAdmin(true);
+ $user->setSuperAdmin($superAdmin);
+
+ return $user;
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 2bddc28..f46cfb3 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -11,6 +11,20 @@
// needed to avoid failed tests when other timezones than UTC are configured for PHP
date_default_timezone_set('UTC');
+$hasTests = false;
+$testsIterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(__DIR__, FilesystemIterator::SKIP_DOTS));
+foreach ($testsIterator as $testFile) {
+ if (str_ends_with($testFile->getFilename(), 'Test.php')) {
+ $hasTests = true;
+ break;
+ }
+}
+unset($testsIterator);
+
+if (!$hasTests) {
+ return;
+}
+
// we want final classes in code but we need non-final classes in tests
// after trying many solutions (see https://tomasvotruba.com/blog/2019/03/28/how-to-mock-final-classes-in-phpunit/)
// none was reliable enough, so this custom solution removes the 'final' keyword
@@ -37,6 +51,29 @@
}
$autoload = require $file;
+$databaseAvailable = extension_loaded('pdo_sqlite');
+putenv(sprintf('SFS_USER_TEST_DATABASE_AVAILABLE=%d', $databaseAvailable ? 1 : 0));
+$_SERVER['SFS_USER_TEST_DATABASE_AVAILABLE'] = $databaseAvailable ? '1' : '0';
+$_ENV['SFS_USER_TEST_DATABASE_AVAILABLE'] = $databaseAvailable ? '1' : '0';
+
+if (!$databaseAvailable) {
+ return;
+}
+
+$cacheDir = sys_get_temp_dir().'/com.github.softspring.userbundle/tests/var/test/cache';
+if (is_dir($cacheDir)) {
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($cacheDir, FilesystemIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ($iterator as $item) {
+ $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
+ }
+
+ rmdir($cacheDir);
+}
+
$application = new Application(new Kernel());
$application->setAutoExit(false);
@@ -49,7 +86,7 @@
$input = new ArrayInput(['command' => 'doctrine:schema:create']);
$application->run($input, new ConsoleOutput());
-$input = new ArrayInput(['command' => 'doctrine:fixtures:load', '--no-interaction' => true, '--append' => false]);
+$input = new ArrayInput(['command' => 'doctrine:fixtures:load', '--no-interaction' => true, '--append' => false, '--group' => ['test_application']]);
$application->run($input, new ConsoleOutput());
-unset($input, $application);
\ No newline at end of file
+unset($input, $application);