diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 44477a5b..db3651cb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,10 +9,10 @@ jobs: strategy: matrix: php: [8.1, 8.2, 8.3] - symfony: ["5.4.*", "6.4.*", "7.0.*"] + symfony: ["5.4.*", "6.4.*", "7.1.*"] exclude: - php: 8.1 - symfony: "7.0.*" + symfony: "7.1.*" steps: - name: Checkout code @@ -42,13 +42,13 @@ jobs: path: framework-tests ref: "6.4" - - name: Checkout Symfony 7.0 Sample - if: "matrix.symfony == '7.0.*'" + - name: Checkout Symfony 7.1 Sample + if: "matrix.symfony == '7.1.*'" uses: actions/checkout@v4 with: repository: Codeception/symfony-module-tests path: framework-tests - ref: "7.0" + ref: "7.1" - name: Get composer cache directory id: composer-cache diff --git a/composer.json b/composer.json index 1421734d..684c23c1 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "symfony/config": "^5.4 | ^6.4 | ^7.0", "symfony/dependency-injection": "^5.4 | ^6.4 | ^7.0", "symfony/dom-crawler": "^5.4 | ^6.4 | ^7.0", + "symfony/dotenv": "^5.4 | ^6.4 | ^7.0", "symfony/error-handler": "^5.4 | ^6.4 | ^7.0", "symfony/filesystem": "^5.4 | ^6.4 | ^7.0", "symfony/form": "^5.4 | ^6.4 | ^7.0", @@ -49,6 +50,7 @@ "symfony/security-csrf": "^5.4 | ^6.4 | ^7.0", "symfony/security-http": "^5.4 | ^6.4 | ^7.0", "symfony/twig-bundle": "^5.4 | ^6.4 | ^7.0", + "symfony/validator": "^5.4 | ^6.4 | ^7.0", "symfony/var-exporter": "^5.4 | ^6.4 | ^7.0", "vlucas/phpdotenv": "^4.2 | ^5.4" }, diff --git a/src/Codeception/Module/Symfony.php b/src/Codeception/Module/Symfony.php index 13a9f6df..6323e523 100644 --- a/src/Codeception/Module/Symfony.php +++ b/src/Codeception/Module/Symfony.php @@ -24,14 +24,17 @@ use Codeception\Module\Symfony\SessionAssertionsTrait; use Codeception\Module\Symfony\TimeAssertionsTrait; use Codeception\Module\Symfony\TwigAssertionsTrait; +use Codeception\Module\Symfony\ValidatorAssertionsTrait; use Codeception\TestInterface; use Doctrine\ORM\EntityManagerInterface; use Exception; +use LogicException; use ReflectionClass; use ReflectionException; use Symfony\Bundle\SecurityBundle\DataCollector\SecurityDataCollector; use Symfony\Component\BrowserKit\AbstractBrowser; use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\Dotenv\Dotenv; use Symfony\Component\Finder\Finder; use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface; use Symfony\Component\HttpKernel\DataCollector\TimeDataCollector; @@ -83,6 +86,7 @@ * * `cache_router`: 'false' - Enable router caching between tests in order to [increase performance](http://lakion.com/blog/how-did-we-speed-up-sylius-behat-suite-with-blackfire) * * `rebootable_client`: 'true' - Reboot client's kernel before each request * * `guard`: 'false' - Enable custom authentication system with guard (only for Symfony 5.4) + * * `bootstrap`: 'false' - Enable the test environment setup with the tests/bootstrap.php file if it exists or with Symfony DotEnv otherwise. If false, it does nothing. * * `authenticator`: 'false' - Reboot client's kernel before each request (only for Symfony 6.0 or higher) * * #### Sample `Functional.suite.yml` @@ -145,6 +149,7 @@ class Symfony extends Framework implements DoctrineProvider, PartedModule use SessionAssertionsTrait; use TimeAssertionsTrait; use TwigAssertionsTrait; + use ValidatorAssertionsTrait; public Kernel $kernel; @@ -165,6 +170,7 @@ class Symfony extends Framework implements DoctrineProvider, PartedModule 'em_service' => 'doctrine.orm.entity_manager', 'rebootable_client' => true, 'authenticator' => false, + 'bootstrap' => false, 'guard' => false ]; @@ -202,6 +208,9 @@ public function _initialize(): void } $this->kernel = new $this->kernelClass($this->config['environment'], $this->config['debug']); + if($this->config['bootstrap']) { + $this->bootstrapEnvironment(); + } $this->kernel->boot(); if ($this->config['cache_router'] === true) { @@ -457,6 +466,26 @@ protected function getInternalDomains(): array return array_unique($internalDomains); } + private function bootstrapEnvironment(): void + { + $bootstrapFile = $this->kernel->getProjectDir() . '/tests/bootstrap.php'; + + if (file_exists($bootstrapFile)) { + require_once $bootstrapFile; + } else { + if (!method_exists(Dotenv::class, 'bootEnv')) { + throw new LogicException( + "Symfony DotEnv is missing. Try running 'composer require symfony/dotenv'\n" . + "If you can't install DotEnv add your env files to the 'params' key in codeception.yml\n" . + "or update your symfony/framework-bundle recipe by running:\n" . + 'composer recipes:install symfony/framework-bundle --force' + ); + } + $_ENV['APP_ENV'] = $this->config['environment']; + (new Dotenv())->bootEnv('.env'); + } + } + /** * Ensures autoloader loading of additional directories. * It is only required for CI jobs to run correctly. diff --git a/src/Codeception/Module/Symfony/ParameterAssertionsTrait.php b/src/Codeception/Module/Symfony/ParameterAssertionsTrait.php index 61c98ddd..ecbbbbc7 100644 --- a/src/Codeception/Module/Symfony/ParameterAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/ParameterAssertionsTrait.php @@ -16,6 +16,7 @@ trait ParameterAssertionsTrait * grabParameter('app.business_name'); * ``` + * This only works for explicitly set parameters (just using `bind` for Symfony's dependency injection is not enough). */ public function grabParameter(string $parameterName): array|bool|string|int|float|UnitEnum|null { diff --git a/src/Codeception/Module/Symfony/SessionAssertionsTrait.php b/src/Codeception/Module/Symfony/SessionAssertionsTrait.php index 7d26314d..47e40fc3 100644 --- a/src/Codeception/Module/Symfony/SessionAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/SessionAssertionsTrait.php @@ -7,9 +7,14 @@ use Symfony\Component\BrowserKit\Cookie; use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Guard\Token\PostAuthenticationGuardToken; +use Symfony\Component\Security\Guard\Token\GuardTokenInterface; +use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken; use Symfony\Component\Security\Http\Logout\LogoutUrlGenerator; use function is_int; @@ -32,12 +37,20 @@ trait SessionAssertionsTrait */ public function amLoggedInAs(UserInterface $user, string $firewallName = 'main', string $firewallContext = null): void { - $session = $this->getCurrentSession(); - $roles = $user->getRoles(); + $token = $this->createAuthenticationToken($user, $firewallName); + $this->loginWithToken($token, $firewallName, $firewallContext); + } - $token = $this->createAuthenticationToken($user, $firewallName, $roles); + public function amLoggedInWithToken(TokenInterface $token, string $firewallName = 'main', string $firewallContext = null): void + { + $this->loginWithToken($token, $firewallName, $firewallContext); + } + + protected function loginWithToken(TokenInterface $token, string $firewallName, ?string $firewallContext): void + { $this->getTokenStorage()->setToken($token); + $session = $this->getCurrentSession(); $sessionKey = $firewallContext ? "_security_{$firewallContext}" : "_security_{$firewallName}"; $session->set($sessionKey, serialize($token)); $session->save(); @@ -174,6 +187,11 @@ protected function getLogoutUrlGenerator(): ?LogoutUrlGenerator return $this->getService('security.logout_url_generator'); } + protected function getAuthenticator(): ?AuthenticatorInterface + { + return $this->getService(AuthenticatorInterface::class); + } + protected function getCurrentSession(): SessionInterface { $container = $this->_getContainer(); @@ -194,18 +212,24 @@ protected function getSymfonyMajorVersion(): int } /** - * @return UsernamePasswordToken|PostAuthenticationGuardToken|PostAuthenticationToken + * @return TokenInterface|GuardTokenInterface */ - protected function createAuthenticationToken(UserInterface $user, string $firewallName, array $roles) + protected function createAuthenticationToken(UserInterface $user, string $firewallName) { + $roles = $user->getRoles(); if ($this->getSymfonyMajorVersion() < 6) { return $this->config['guard'] ? new PostAuthenticationGuardToken($user, $firewallName, $roles) : new UsernamePasswordToken($user, null, $firewallName, $roles); } - return $this->config['authenticator'] - ? new PostAuthenticationToken($user, $firewallName, $roles) - : new UsernamePasswordToken($user, $firewallName, $roles); + if ($this->config['authenticator']) { + if ($authenticator = $this->getAuthenticator()) { + $passport = new SelfValidatingPassport(new UserBadge($user->getUserIdentifier(), fn () => $user)); + return $authenticator->createToken($passport, $firewallName); + } + return new PostAuthenticationToken($user, $firewallName, $roles); + } + return new UsernamePasswordToken($user, $firewallName, $roles); } } diff --git a/src/Codeception/Module/Symfony/ValidatorAssertionsTrait.php b/src/Codeception/Module/Symfony/ValidatorAssertionsTrait.php new file mode 100644 index 00000000..ca82e196 --- /dev/null +++ b/src/Codeception/Module/Symfony/ValidatorAssertionsTrait.php @@ -0,0 +1,106 @@ +dontSeeViolatedConstraint($subject); + * $I->dontSeeViolatedConstraint($subject, 'propertyName'); + * $I->dontSeeViolatedConstraint($subject, 'propertyName', 'Symfony\Validator\ConstraintClass'); + * ``` + */ + public function dontSeeViolatedConstraint(mixed $subject, ?string $propertyPath = null, ?string $constraint = null): void + { + $violations = $this->getViolationsForSubject($subject, $propertyPath, $constraint); + $this->assertCount(0, $violations, 'Constraint violations found.'); + } + + /** + * Asserts that the given subject passes validation. + * This assertion does not concern the exact number of violations. + * + * ```php + * seeViolatedConstraint($subject); + * $I->seeViolatedConstraint($subject, 'propertyName'); + * $I->seeViolatedConstraint($subject, 'propertyName', 'Symfony\Validator\ConstraintClass'); + * ``` + */ + public function seeViolatedConstraint(mixed $subject, ?string $propertyPath = null, ?string $constraint = null): void + { + $violations = $this->getViolationsForSubject($subject, $propertyPath, $constraint); + $this->assertNotCount(0, $violations, 'No constraint violations found.'); + } + + /** + * Asserts the exact number of violations for the given subject. + * + * ```php + * seeViolatedConstraintsCount(3, $subject); + * $I->seeViolatedConstraintsCount(2, $subject, 'propertyName'); + * ``` + */ + public function seeViolatedConstraintsCount(int $expected, mixed $subject, ?string $propertyPath = null, ?string $constraint = null): void + { + $violations = $this->getViolationsForSubject($subject, $propertyPath, $constraint); + $this->assertCount($expected, $violations); + } + + /** + * Asserts that a constraint violation message or a part of it is present in the subject's violations. + * + * ```php + * seeViolatedConstraintMessage('too short', $user, 'address'); + * ``` + */ + public function seeViolatedConstraintMessage(string $expected, mixed $subject, string $propertyPath): void + { + $violations = $this->getViolationsForSubject($subject, $propertyPath); + $containsExpected = false; + foreach ($violations as $violation) { + if ($violation->getPropertyPath() === $propertyPath && str_contains($violation->getMessage(), $expected)) { + $containsExpected = true; + break; + } + } + + $this->assertTrue($containsExpected, 'The violation messages do not contain: ' . $expected); + } + + /** @return ConstraintViolationInterface[] */ + protected function getViolationsForSubject(mixed $subject, ?string $propertyPath = null, ?string $constraint = null): array + { + $validator = $this->getValidatorService(); + $violations = $propertyPath ? $validator->validateProperty($subject, $propertyPath) : $validator->validate($subject); + + $violations = iterator_to_array($violations); + + if ($constraint !== null) { + return array_filter( + $violations, + static fn($violation): bool => $violation->getConstraint()::class === $constraint && + ($propertyPath === null || $violation->getPropertyPath() === $propertyPath) + ); + } + + return $violations; + } + + protected function getValidatorService(): ValidatorInterface + { + return $this->grabService(ValidatorInterface::class); + } +}