From 2a2bb715d05156c6fdc3a7c903caa3a4383e6885 Mon Sep 17 00:00:00 2001 From: "Javi H. Gil" Date: Fri, 27 Mar 2026 12:29:26 +0100 Subject: [PATCH 1/4] Update component --- .github/workflows/ci.yml | 27 ++- .gitignore | 3 +- CONTRIBUTING.md | 11 ++ FEATURES.md | 54 ++++++ README.md | 26 ++- SECURITY.md | 29 +++ composer.json | 6 +- config/services/mailer.yaml | 5 + phpunit.xml.dist | 23 ++- src/DependencyInjection/SfsUserExtension.php | 2 +- src/Doctrine/Filter/AdminFilter.php | 2 +- src/Doctrine/Filter/UserFilter.php | 2 +- src/Mime/Example/Model/ExampleUser.php | 4 + .../Voter/AdminAdministratorsActionsVoter.php | 3 +- .../Authorization/Voter/SwitchUserVoter.php | 3 +- tests/Functional/AbstractWebTestCase.php | 56 ++++++ tests/Functional/AdminFlowsTest.php | 56 ++++++ tests/Functional/PublicFlowsTest.php | 130 +++++++++++++ .../config/packages/doctrine.yaml | 4 +- .../config/packages/framework.yaml | 1 + .../config/packages/security.yaml | 47 +++-- .../config/packages/sfs_user.yaml | 10 +- .../TestApplication/config/packages/twig.yaml | 4 +- tests/TestApplication/config/routes.yaml | 27 +++ tests/TestApplication/config/services.yaml | 7 +- .../src/DataFixtures/AppFixtures.php | 35 +++- tests/TestApplication/src/Entity/User.php | 15 +- tests/TestApplication/src/Kernel.php | 6 +- tests/TestApplication/src/TestUserMailer.php | 22 +++ .../templates/admin/layout.html.twig | 7 + .../TestApplication/templates/base.html.twig | 10 + .../forms/admin-horizontal.html.twig | 1 + .../sfs_components/paginator/table.html.twig | 6 + .../Unit/Doctrine/Filter/AdminFilterTest.php | 50 +++++ tests/Unit/Doctrine/Filter/UserFilterTest.php | 50 +++++ .../AdminAdministratorsActionsVoterTest.php | 177 ++++++++++++++++++ .../Voter/SwitchUserVoterTest.php | 168 +++++++++++++++++ tests/bootstrap.php | 41 +++- 38 files changed, 1060 insertions(+), 70 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 FEATURES.md create mode 100644 SECURITY.md create mode 100644 tests/Functional/AbstractWebTestCase.php create mode 100644 tests/Functional/AdminFlowsTest.php create mode 100644 tests/Functional/PublicFlowsTest.php create mode 100644 tests/TestApplication/src/TestUserMailer.php create mode 100644 tests/TestApplication/templates/admin/layout.html.twig create mode 100644 tests/TestApplication/templates/base.html.twig create mode 100644 tests/TestApplication/templates/sfs_components/forms/admin-horizontal.html.twig create mode 100644 tests/TestApplication/templates/sfs_components/paginator/table.html.twig create mode 100644 tests/Unit/Doctrine/Filter/AdminFilterTest.php create mode 100644 tests/Unit/Doctrine/Filter/UserFilterTest.php create mode 100644 tests/Unit/Security/Authorization/Voter/AdminAdministratorsActionsVoterTest.php create mode 100644 tests/Unit/Security/Authorization/Voter/SwitchUserVoterTest.php 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..ab16dfb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ 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 \ 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 -![Latest Stable](https://img.shields.io/packagist/v/softspring/user-bundle?label=stable&style=flat-square) -![Latest Unstable](https://img.shields.io/packagist/v/softspring/user-bundle?label=unstable&style=flat-square&include_prereleases) -![License](https://img.shields.io/packagist/l/softspring/user-bundle?style=flat-square) -![PHP Version](https://img.shields.io/packagist/dependency-v/softspring/user-bundle/php?style=flat-square) -![Downloads](https://img.shields.io/packagist/dt/softspring/user-bundle?style=flat-square) +[![Latest Stable](https://img.shields.io/packagist/v/softspring/user-bundle?label=stable&style=flat-square)](https://github.com/softspring/user-bundle/releases) +[![Latest Unstable](https://img.shields.io/packagist/v/softspring/user-bundle?label=unstable&style=flat-square&include_prereleases)](https://github.com/softspring/user-bundle/releases) +[![License](https://img.shields.io/packagist/l/softspring/user-bundle?style=flat-square)](https://github.com/softspring/user-bundle/blob/6.0/LICENSE) +[![PHP Version](https://img.shields.io/packagist/dependency-v/softspring/user-bundle/php?style=flat-square)](https://github.com/softspring/user-bundle/blob/6.0/composer.json) +[![Downloads](https://img.shields.io/packagist/dt/softspring/user-bundle?style=flat-square)](https://packagist.org/packages/softspring/user-bundle) [![CI](https://img.shields.io/github/actions/workflow/status/softspring/user-bundle/ci.yml?branch=6.0&style=flat-square&label=CI)](https://github.com/softspring/user-bundle/actions/workflows/ci.yml) -![Coverage](https://raw.githubusercontent.com/softspring/user-bundle/6.0/.github/badges/coverage.svg) +[![Coverage](https://img.shields.io/codecov/c/github/softspring/user-bundle?branch=6.0&style=flat-square)](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..cd1ea41 100644 --- a/composer.json +++ b/composer.json @@ -38,13 +38,13 @@ "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/mailer": "^6.4 || ^7.4 || ^8.0" }, "suggest": { "hwi/oauth-bundle": "Integrate oauth clients", @@ -85,7 +85,7 @@ ], "run-tests": [ "composer validate --strict --no-check-lock --ansi", - "# XDEBUG_MODE=coverage vendor/bin/phpunit", + "XDEBUG_MODE=coverage vendor/bin/phpunit", "vendor/bin/rector process src tests --dry-run", "vendor/bin/phpstan analyse src tests --level 5", "vendor/bin/php-cs-fixer fix --dry-run --diff", 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..b4a4436 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,7 +2,8 @@ @@ -21,16 +22,32 @@ - tests/ + tests tests/DataCollector/ tests/EventListener/ tests/Form/ - + src + + vendor + tests + + + + + + + + + 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/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/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..d061604 100644 --- a/tests/TestApplication/config/services.yaml +++ b/tests/TestApplication/config/services.yaml @@ -15,4 +15,9 @@ services: 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..cbd1f30 --- /dev/null +++ b/tests/Unit/Doctrine/Filter/AdminFilterTest.php @@ -0,0 +1,50 @@ +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..43b814d --- /dev/null +++ b/tests/Unit/Doctrine/Filter/UserFilterTest.php @@ -0,0 +1,50 @@ +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..8c311ae --- /dev/null +++ b/tests/Unit/Security/Authorization/Voter/AdminAdministratorsActionsVoterTest.php @@ -0,0 +1,177 @@ +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..dbbaa9b --- /dev/null +++ b/tests/Unit/Security/Authorization/Voter/SwitchUserVoterTest.php @@ -0,0 +1,168 @@ +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); From 7ef40f7083dc1d80e51dbbbd3b2ab6e9a91b35da Mon Sep 17 00:00:00 2001 From: "Javi H. Gil" Date: Fri, 27 Mar 2026 12:49:55 +0100 Subject: [PATCH 2/4] Update component --- .gitignore | 3 ++- composer.json | 5 +++-- phpunit.xml.dist | 2 +- rector.php | 4 ++-- src/Command/CreateUserCommand.php | 3 ++- src/Command/InviteUserCommand.php | 3 ++- src/Command/PromoteUserCommand.php | 3 ++- src/Entity/RolesTrait.php | 2 +- src/Model/ConfirmableTrait.php | 4 ++-- tests/Unit/Doctrine/Filter/AdminFilterTest.php | 5 +++-- tests/Unit/Doctrine/Filter/UserFilterTest.php | 5 +++-- .../Voter/AdminAdministratorsActionsVoterTest.php | 3 ++- .../Security/Authorization/Voter/SwitchUserVoterTest.php | 3 ++- 13 files changed, 27 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index ab16dfb..ddfdb0e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ composer.lock .php-cs-fixer.cache .php_cs.cache .phpunit.result.cache -.phpunit.cache \ No newline at end of file +.phpunit.cache +clover.xml \ No newline at end of file diff --git a/composer.json b/composer.json index cd1ea41..ac19d94 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,8 @@ "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/mailer": "^6.4 || ^7.4 || ^8.0", + "symfony/var-exporter": "^6.4 || ^7.4" }, "suggest": { "hwi/oauth-bundle": "Integrate oauth clients", @@ -86,7 +87,7 @@ "run-tests": [ "composer validate --strict --no-check-lock --ansi", "XDEBUG_MODE=coverage vendor/bin/phpunit", - "vendor/bin/rector process src tests --dry-run", + "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/phpunit.xml.dist b/phpunit.xml.dist index b4a4436..5869fa7 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -9,7 +9,7 @@ - + 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/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/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/tests/Unit/Doctrine/Filter/AdminFilterTest.php b/tests/Unit/Doctrine/Filter/AdminFilterTest.php index cbd1f30..5d828fe 100644 --- a/tests/Unit/Doctrine/Filter/AdminFilterTest.php +++ b/tests/Unit/Doctrine/Filter/AdminFilterTest.php @@ -5,6 +5,7 @@ use Doctrine\ORM\Mapping\ClassMetadata; use PHPUnit\Framework\TestCase; use ReflectionClass; +use stdClass; use Softspring\UserBundle\Doctrine\Filter\AdminFilter; use Softspring\UserBundle\Tests\TestApplication\Entity\User; @@ -23,7 +24,7 @@ public function testAddsAdminConstraintForAdminAwareEntities(): void public function testReturnsEmptyConstraintForUnsupportedEntities(): void { $filter = (new ReflectionClass(AdminFilter::class))->newInstanceWithoutConstructor(); - $metadata = $this->createMetadata(\stdClass::class); + $metadata = $this->createMetadata(stdClass::class); $constraint = $filter->addFilterConstraint($metadata, 'user_alias'); @@ -41,7 +42,7 @@ public function __construct(string $class) $this->reflectionClass = new ReflectionClass($class); } - public function getReflectionClass(): ?ReflectionClass + public function getReflectionClass(): ReflectionClass { return $this->reflectionClass; } diff --git a/tests/Unit/Doctrine/Filter/UserFilterTest.php b/tests/Unit/Doctrine/Filter/UserFilterTest.php index 43b814d..88ee4f1 100644 --- a/tests/Unit/Doctrine/Filter/UserFilterTest.php +++ b/tests/Unit/Doctrine/Filter/UserFilterTest.php @@ -5,6 +5,7 @@ use Doctrine\ORM\Mapping\ClassMetadata; use PHPUnit\Framework\TestCase; use ReflectionClass; +use stdClass; use Softspring\UserBundle\Doctrine\Filter\UserFilter; use Softspring\UserBundle\Tests\TestApplication\Entity\User; @@ -23,7 +24,7 @@ public function testAddsRegularUserConstraintForAdminAwareEntities(): void public function testReturnsEmptyConstraintForUnsupportedEntities(): void { $filter = (new ReflectionClass(UserFilter::class))->newInstanceWithoutConstructor(); - $metadata = $this->createMetadata(\stdClass::class); + $metadata = $this->createMetadata(stdClass::class); $constraint = $filter->addFilterConstraint($metadata, 'user_alias'); @@ -41,7 +42,7 @@ public function __construct(string $class) $this->reflectionClass = new ReflectionClass($class); } - public function getReflectionClass(): ?ReflectionClass + 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 index 8c311ae..2a89554 100644 --- a/tests/Unit/Security/Authorization/Voter/AdminAdministratorsActionsVoterTest.php +++ b/tests/Unit/Security/Authorization/Voter/AdminAdministratorsActionsVoterTest.php @@ -2,6 +2,7 @@ namespace Softspring\UserBundle\Tests\Unit\Security\Authorization\Voter; +use BadMethodCallException; use PHPUnit\Framework\TestCase; use Softspring\UserBundle\Security\Authorization\Voter\AdminAdministratorsActionsVoter; use Softspring\UserBundle\Tests\TestApplication\Entity\User; @@ -117,7 +118,7 @@ public function getUser(): ?SymfonyUserInterface public function setUser(SymfonyUserInterface $user): void { - throw new \BadMethodCallException('Not implemented for tests.'); + throw new BadMethodCallException('Not implemented for tests.'); } public function eraseCredentials(): void diff --git a/tests/Unit/Security/Authorization/Voter/SwitchUserVoterTest.php b/tests/Unit/Security/Authorization/Voter/SwitchUserVoterTest.php index dbbaa9b..8e36cb0 100644 --- a/tests/Unit/Security/Authorization/Voter/SwitchUserVoterTest.php +++ b/tests/Unit/Security/Authorization/Voter/SwitchUserVoterTest.php @@ -2,6 +2,7 @@ namespace Softspring\UserBundle\Tests\Unit\Security\Authorization\Voter; +use BadMethodCallException; use PHPUnit\Framework\TestCase; use Softspring\UserBundle\Security\Authorization\Voter\SwitchUserVoter; use Softspring\UserBundle\Tests\TestApplication\Entity\User; @@ -108,7 +109,7 @@ public function getUser(): ?SymfonyUserInterface public function setUser(SymfonyUserInterface $user): void { - throw new \BadMethodCallException('Not implemented for tests.'); + throw new BadMethodCallException('Not implemented for tests.'); } public function eraseCredentials(): void From 4c4523dc227a5c4e388acbb3c24bb663f07a59ea Mon Sep 17 00:00:00 2001 From: "Javi H. Gil" Date: Fri, 27 Mar 2026 12:54:42 +0100 Subject: [PATCH 3/4] Update component --- tests/TestApplication/config/services.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/TestApplication/config/services.yaml b/tests/TestApplication/config/services.yaml index d061604..8de9745 100644 --- a/tests/TestApplication/config/services.yaml +++ b/tests/TestApplication/config/services.yaml @@ -10,10 +10,6 @@ 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'] From 1606a78ba90093f4549c812482b60e2229796c26 Mon Sep 17 00:00:00 2001 From: "Javi H. Gil" Date: Fri, 27 Mar 2026 13:07:26 +0100 Subject: [PATCH 4/4] Update component --- .github/workflows/ci.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bafe68f..59fe987 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,17 +37,21 @@ jobs: - name: Validate composer.json and composer.lock run: composer validate --strict + - name: Get Composer cache directory + id: composer-cache-dir + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + - name: Cache Composer packages id: composer-cache uses: actions/cache@v5 with: - path: vendor - key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + path: ${{ steps.composer-cache-dir.outputs.dir }} + key: ${{ runner.os }}-php-${{ matrix.php-versions }}-${{ matrix.composer-prefer }}-${{ hashFiles('composer.json') }} restore-keys: | - ${{ runner.os }}-php- + ${{ runner.os }}-php-${{ matrix.php-versions }}- - name: Install dependencies - run: COMPOSER_ROOT_VERSION=6.0.x-dev composer update --prefer-dist --no-interaction --no-progress ${{ matrix.composer-prefer }} + run: COMPOSER_ROOT_VERSION=6.0.x-dev composer update --prefer-dist --no-interaction --no-progress --prefer-stable ${{ matrix.composer-prefer }} - name: Run phpstan run: ./vendor/bin/phpstan analyse