From d83f581e5d0f2f14ca64dece5da29550486197d6 Mon Sep 17 00:00:00 2001 From: Aaron Gustavo Nieves <64917965+TavoNiievez@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:25:07 -0500 Subject: [PATCH] Add Doctrine schema and voter assertions seeDoctrineSchemaIsValid() is the in-process equivalent of `bin/console doctrine:schema:validate`. It reports invalid mappings and a database schema that has drifted from the metadata, so a missing migration surfaces as one clear failure instead of unrelated errors spread over the rest of the suite. seeUserIsGranted() and dontSeeUserIsGranted() run the application's voters through Security::isGranted(). seeUserHasRole() only covers attributes checked without a subject, which left custom voters -- the part of the authorization layer applications actually write -- reachable only through full HTTP round trips. The test application gains a UserVoter fixture. It implements VoterInterface rather than extending Voter, whose abstract voteOnAttribute() signature gained a Vote argument in Symfony 8.1 and is therefore not compatible across every supported Symfony version. CONTRIBUTING.md documents the see*/assert* naming convention and records that an assertion describes what the application does: checks about the machine it runs on belong in CI, and container parameters are already reachable through grabParameter(). --- CONTRIBUTING.md | 14 ++++++ .../Symfony/DoctrineAssertionsTrait.php | 37 ++++++++++++++ .../Symfony/SecurityAssertionsTrait.php | 48 +++++++++++++++++++ tests/DoctrineAssertionsTest.php | 5 ++ tests/SecurityAssertionsTest.php | 18 +++++++ tests/_app/Security/UserVoter.php | 36 ++++++++++++++ tests/_app/config/services.php | 3 ++ 7 files changed, 161 insertions(+) create mode 100644 tests/_app/Security/UserVoter.php diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e2eb5a9e..e502a63c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,20 @@ First of all: Contributions are very welcome! +## Assertion Naming Conventions + +When adding new assertions, prefer these conventions: + +- Use `see*` / `dontSee*` for high-level, actor-style checks (Codeception style), where the method expresses an observed application state. +- Use `assert*` for direct value/constraint comparisons that map closely to PHPUnit/Symfony assertion semantics. + +An assertion should describe what the application does: a request, a service, a message, +a security decision. Checks about the machine the application runs on (writable directories, +project layout, build artifacts, `.env` files) belong in CI or a healthcheck, not in this module. + +Container parameters are already reachable through `grabParameter()`, so a value that can be +asserted with `grabParameter()` plus a plain PHPUnit assertion does not need a dedicated method. + **Does your change require a test?** ## No, my change does not require a test diff --git a/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php b/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php index 59aff3c9..110d6814 100644 --- a/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php @@ -5,6 +5,7 @@ namespace Codeception\Module\Symfony; use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\Tools\SchemaValidator; use PHPUnit\Framework\Assert; use Symfony\Bridge\Doctrine\DataCollector\DoctrineDataCollector; @@ -166,6 +167,42 @@ public function seeNumRecords(int $expectedNum, string $className, array $criter ); } + /** + * Asserts that the Doctrine mapping is valid and that the database schema is in sync with it. + * + * In-process equivalent of `bin/console doctrine:schema:validate`: it catches mapping mistakes + * and missing migrations before they surface as unrelated failures in other tests. + * + * The entity manager checked is the one configured in the module's `em_service` option. + * + * ```php + * seeDoctrineSchemaIsValid(); + * ``` + */ + public function seeDoctrineSchemaIsValid(): void + { + if (!class_exists(SchemaValidator::class)) { + Assert::fail("The 'seeDoctrineSchemaIsValid' assertion requires the 'doctrine/orm' package."); + } + + $validator = new SchemaValidator($this->_getEntityManager()); + + $mappingErrors = []; + foreach ($validator->validateMapping() as $className => $classErrors) { + $mappingErrors[] = sprintf(' - %s: %s', $className, implode('; ', $classErrors)); + } + + if ($mappingErrors !== []) { + Assert::fail(sprintf("The Doctrine mapping is invalid:\n%s", implode("\n", $mappingErrors))); + } + + $this->assertTrue( + $validator->schemaInSyncWithMetadata(), + 'The database schema is not in sync with the current mapping. A migration is missing.' + ); + } + private function grabDoctrineCollector(string $function): DoctrineDataCollector { if (!class_exists(DoctrineDataCollector::class)) { diff --git a/src/Codeception/Module/Symfony/SecurityAssertionsTrait.php b/src/Codeception/Module/Symfony/SecurityAssertionsTrait.php index 85b32826..6c477580 100644 --- a/src/Codeception/Module/Symfony/SecurityAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/SecurityAssertionsTrait.php @@ -11,6 +11,7 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; +use function get_debug_type; use function sprintf; trait SecurityAssertionsTrait @@ -131,6 +132,42 @@ public function seeUserPasswordDoesNotNeedRehash(?UserInterface $user = null): v $this->assertFalse($this->grabPasswordHasherService()->needsRehash($userToValidate), 'User password needs rehash.'); } + /** + * Checks that the current user is granted an attribute, optionally over a subject. + * + * This runs the application's voters through `Security::isGranted()`, so unlike + * `seeUserHasRole()` it also covers custom voters and permissions that depend on + * the object being accessed. + * + * ```php + * seeUserIsGranted('POST_EDIT', $post); + * ``` + */ + public function seeUserIsGranted(string $attribute, mixed $subject = null): void + { + $this->assertTrue( + $this->grabSecurityService()->isGranted($attribute, $subject), + $this->buildIsGrantedMessage('is not granted', $attribute, $subject) + ); + } + + /** + * Checks that the current user is not granted an attribute, optionally over a subject. + * + * ```php + * dontSeeUserIsGranted('POST_DELETE', $post); + * ``` + */ + public function dontSeeUserIsGranted(string $attribute, mixed $subject = null): void + { + $this->assertFalse( + $this->grabSecurityService()->isGranted($attribute, $subject), + $this->buildIsGrantedMessage('is granted', $attribute, $subject) + ); + } + private function getAuthenticatedUser(): UserInterface { return $this->grabSecurityService()->getUser() ?? Assert::fail('No user found in session to perform this check.'); @@ -154,4 +191,15 @@ protected function grabPasswordHasherService(): UserPasswordHasherInterface /** @var UserPasswordHasherInterface */ return $this->grabService('security.password_hasher'); } + + private function buildIsGrantedMessage(string $verb, string $attribute, mixed $subject): string + { + return sprintf( + 'User %s %s "%s"%s.', + $this->grabSecurityService()->getUser()?->getUserIdentifier() ?? 'anon.', + $verb, + $attribute, + $subject === null ? '' : sprintf(' on %s', get_debug_type($subject)) + ); + } } diff --git a/tests/DoctrineAssertionsTest.php b/tests/DoctrineAssertionsTest.php index a23f2a6f..6b2c720b 100644 --- a/tests/DoctrineAssertionsTest.php +++ b/tests/DoctrineAssertionsTest.php @@ -54,4 +54,9 @@ public function testSeeNumRecords(): void { $this->seeNumRecords(1, User::class); } + + public function testSeeDoctrineSchemaIsValid(): void + { + $this->seeDoctrineSchemaIsValid(); + } } diff --git a/tests/SecurityAssertionsTest.php b/tests/SecurityAssertionsTest.php index 7840c805..3a9739c6 100644 --- a/tests/SecurityAssertionsTest.php +++ b/tests/SecurityAssertionsTest.php @@ -8,6 +8,7 @@ use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\BrowserKit\Cookie; use Tests\App\Entity\User; +use Tests\App\Security\UserVoter; use Tests\Support\CodeceptTestCase; final class SecurityAssertionsTest extends CodeceptTestCase @@ -62,6 +63,23 @@ public function testSeeUserPasswordDoesNotNeedRehash(): void $this->seeUserPasswordDoesNotNeedRehash(); } + public function testSeeUserIsGranted(): void + { + $user = $this->createTestUser(['ROLE_USER']); + $this->client->loginUser($user); + + $this->seeUserIsGranted('ROLE_USER'); + $this->seeUserIsGranted(UserVoter::EDIT, $user); + } + + public function testDontSeeUserIsGranted(): void + { + $this->client->loginUser($this->createTestUser(['ROLE_USER'])); + + $this->dontSeeUserIsGranted('ROLE_ADMIN'); + $this->dontSeeUserIsGranted(UserVoter::EDIT, User::create('jane_doe@gmail.com', 'secret')); + } + private function createTestUser(array $roles): User { return User::create('john_doe@gmail.com', $this->grabPasswordHasherService()->hashPassword(User::create('tmp', ''), '123456'), $roles); diff --git a/tests/_app/Security/UserVoter.php b/tests/_app/Security/UserVoter.php new file mode 100644 index 00000000..e490859f --- /dev/null +++ b/tests/_app/Security/UserVoter.php @@ -0,0 +1,36 @@ +getUser(); + + return $user instanceof User && $user->getUserIdentifier() === $subject->getUserIdentifier() + ? self::ACCESS_GRANTED + : self::ACCESS_DENIED; + } +} diff --git a/tests/_app/config/services.php b/tests/_app/config/services.php index 50715ebe..ad61e17c 100644 --- a/tests/_app/config/services.php +++ b/tests/_app/config/services.php @@ -31,6 +31,7 @@ use Tests\App\Repository\UserRepository; use Tests\App\Repository\UserRepositoryInterface; use Tests\App\Security\TestUserProvider; +use Tests\App\Security\UserVoter; use Twig\Extension\ProfilerExtension; use Twig\Profiler\Profile; @@ -61,6 +62,8 @@ ->arg('$repository', service(UserRepository::class)) ->tag('security.user_provider'); + $services->set(UserVoter::class); + $services->alias('security.password_hasher', 'security.user_password_hasher')->public(); if (class_exists(Security::class)) {