diff --git a/.gitignore b/.gitignore index a816930..2d16f47 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ /vendor/ /composer.lock /framework-tests -/.php-cs-fixer.cache \ No newline at end of file +/.php-cs-fixer.cache diff --git a/src/Codeception/Module/Symfony.php b/src/Codeception/Module/Symfony.php index 0f98cf1..f4b3f98 100644 --- a/src/Codeception/Module/Symfony.php +++ b/src/Codeception/Module/Symfony.php @@ -15,6 +15,7 @@ use Codeception\Module\Symfony\DataCollectorName; use Codeception\Module\Symfony\DoctrineAssertionsTrait; use Codeception\Module\Symfony\DomCrawlerAssertionsTrait; +use Codeception\Module\Symfony\EnvironmentAssertionsTrait; use Codeception\Module\Symfony\EventsAssertionsTrait; use Codeception\Module\Symfony\FormAssertionsTrait; use Codeception\Module\Symfony\HttpClientAssertionsTrait; @@ -62,10 +63,10 @@ use function class_exists; use function codecept_root_dir; use function count; +use function extension_loaded; use function file_exists; use function implode; use function in_array; -use function extension_loaded; use function ini_get; use function ini_set; use function is_object; @@ -150,6 +151,7 @@ class Symfony extends Framework implements DoctrineProvider, PartedModule use ConsoleAssertionsTrait; use DoctrineAssertionsTrait; use DomCrawlerAssertionsTrait; + use EnvironmentAssertionsTrait; use EventsAssertionsTrait; use FormAssertionsTrait; use HttpClientAssertionsTrait; diff --git a/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php b/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php index 59c022c..852c107 100644 --- a/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php @@ -4,12 +4,21 @@ namespace Codeception\Module\Symfony; +use Doctrine\DBAL\Connection; +use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\Tools\SchemaValidator; +use Doctrine\Persistence\ManagerRegistry; use PHPUnit\Framework\Assert; +use Throwable; +use function implode; use function interface_exists; +use function is_dir; use function is_object; +use function is_string; use function is_subclass_of; +use function is_writable; use function json_encode; use function sprintf; @@ -107,4 +116,99 @@ public function seeNumRecords(int $expectedNum, string $className, array $criter ) ); } + + /** + * Asserts that Doctrine can connect to a database. + * + * ```php + * seeDoctrineDatabaseIsUp(); + * $I->seeDoctrineDatabaseIsUp('custom'); + * ``` + * + * @param non-empty-string $connectionName The name of the Doctrine connection to check. + */ + public function seeDoctrineDatabaseIsUp(string $connectionName = 'default'): void + { + try { + /** @var ManagerRegistry $doctrine */ + $doctrine = $this->grabService('doctrine'); + /** @var Connection $connection */ + $connection = $doctrine->getConnection($connectionName); + $connection->executeQuery($connection->getDatabasePlatform()->getDummySelectSQL()); + } catch (Throwable $e) { + Assert::fail(sprintf('Doctrine connection "%s" failed: %s', $connectionName, $e->getMessage())); + } + } + + /** + * Asserts that the Doctrine mapping is valid and the DB schema is in sync for one entity manager. + * Programmatic equivalent of `bin/console doctrine:schema:validate`. + * + * ```php + * seeDoctrineSchemaIsValid(); + * $I->seeDoctrineSchemaIsValid('custom'); + * ``` + * + * @param non-empty-string $entityManagerName + */ + public function seeDoctrineSchemaIsValid(string $entityManagerName = 'default'): void + { + try { + /** @var ManagerRegistry $doctrine */ + $doctrine = $this->grabService('doctrine'); + /** @var EntityManagerInterface $em */ + $em = $doctrine->getManager($entityManagerName); + $validator = new SchemaValidator($em); + $errors = $validator->validateMapping(); + $errorMessages = []; + foreach ($errors as $className => $classErrors) { + $errorMessages[] = sprintf(' - %s: %s', $className, implode('; ', $classErrors)); + } + $this->assertEmpty( + $errors, + sprintf( + "The Doctrine mapping is invalid for the '%s' entity manager:\n%s", + $entityManagerName, + implode("\n", $errorMessages) + ) + ); + + if (!$validator->schemaInSyncWithMetadata()) { + Assert::fail( + sprintf( + 'The database schema is not in sync with the current mapping for the "%s" entity manager. Generate and run a new migration.', + $entityManagerName + ) + ); + } + } catch (Throwable $e) { + Assert::fail( + sprintf('Could not validate Doctrine schema for the "%s" entity manager: %s', $entityManagerName, $e->getMessage()) + ); + } + } + + /** + * Asserts that Doctrine proxy directory is writable for a given entity manager. + * + * ```php + * seeDoctrineProxyDirIsWritable(); + * $I->seeDoctrineProxyDirIsWritable('custom'); + * ``` + */ + public function seeDoctrineProxyDirIsWritable(string $entityManagerName = 'default'): void + { + /** @var ManagerRegistry $doctrine */ + $doctrine = $this->grabService('doctrine'); + /** @var EntityManagerInterface $em */ + $em = $doctrine->getManager($entityManagerName); + $proxyDir = $em->getConfiguration()->getProxyDir(); + + $this->assertIsString($proxyDir, sprintf('Doctrine proxy dir is not configured for EM "%s".', $entityManagerName)); + $this->assertTrue(is_dir($proxyDir), sprintf('Doctrine proxy dir does not exist: %s', $proxyDir)); + $this->assertTrue(is_writable($proxyDir), sprintf('Doctrine proxy dir is not writable: %s', $proxyDir)); + } } diff --git a/src/Codeception/Module/Symfony/EnvironmentAssertionsTrait.php b/src/Codeception/Module/Symfony/EnvironmentAssertionsTrait.php new file mode 100644 index 0000000..10a18e7 --- /dev/null +++ b/src/Codeception/Module/Symfony/EnvironmentAssertionsTrait.php @@ -0,0 +1,336 @@ +seeKernelEnvironmentIs('test'); + * ``` + */ + public function seeKernelEnvironmentIs(string $expectedEnv): void + { + $currentEnv = $this->kernel->getEnvironment(); + $this->assertSame( + $expectedEnv, + $currentEnv, + sprintf('Kernel is running in environment "%s" but expected "%s".', $currentEnv, $expectedEnv) + ); + } + + /** + * Asserts that the application's debug mode is enabled. + * + * ```php + * seeDebugModeEnabled(); + * ``` + */ + public function seeDebugModeEnabled(): void + { + $this->assertTrue($this->kernel->isDebug(), 'Debug mode is expected to be enabled, but it is not.'); + } + + /** + * Asserts that the application's debug mode is disabled. + * + * ```php + * dontSeeDebugModeEnabled(); + * ``` + */ + public function dontSeeDebugModeEnabled(): void + { + $this->assertFalse($this->kernel->isDebug(), 'Debug mode is expected to be disabled, but it is enabled.'); + } + + /** + * Asserts that the current Symfony version satisfies the given comparison. + * + * ```php + * assertSymfonyVersion('>=', '6.4'); + * ``` + */ + public function assertSymfonyVersion(string $operator, string $version, string $message = ''): void + { + $this->assertTrue( + version_compare(Kernel::VERSION, $version, $operator), + $message ?: sprintf('Symfony version %s does not satisfy the constraint: %s %s', Kernel::VERSION, $operator, $version) + ); + } + + /** + * Asserts that `APP_ENV` and `APP_DEBUG` env vars match the Kernel state. + * + * ```php + * seeAppEnvAndDebugMatchKernel(); + * ``` + */ + public function seeAppEnvAndDebugMatchKernel(): void + { + $appEnv = getenv('APP_ENV'); + $appDebug = getenv('APP_DEBUG'); + + if ($appEnv !== false) { + $this->assertSame( + $this->kernel->getEnvironment(), + (string) $appEnv, + sprintf('APP_ENV (%s) differs from Kernel environment (%s).', $appEnv, $this->kernel->getEnvironment()) + ); + } + + if ($appDebug !== false) { + $expected = $this->kernel->isDebug(); + $normalized = in_array(strtolower((string) $appDebug), ['1', 'true', 'yes', 'on'], true); + $this->assertSame( + $expected, + $normalized, + sprintf('APP_DEBUG (%s) differs from Kernel debug (%s).', $appDebug, $expected ? 'true' : 'false') + ); + } + } + + /** + * Asserts that the application's cache directory is writable. + * + * ```php + * seeAppCacheIsWritable(); + * ``` + */ + public function seeAppCacheIsWritable(): void + { + $cacheDir = $this->kernel->getCacheDir(); + $this->assertTrue( + is_writable($cacheDir), + sprintf('Symfony cache directory is not writable: %s', $cacheDir) + ); + } + + /** + * Asserts that the application's log directory is writable. + * + * ```php + * seeAppLogIsWritable(); + * ``` + */ + public function seeAppLogIsWritable(): void + { + $container = $this->_getContainer(); + if ($container->hasParameter('kernel.logs_dir')) { + $value = $container->getParameter('kernel.logs_dir'); + Assert::assertIsString($value); + /** @var string $logDir */ + $logDir = $value; + } else { + $logDir = $this->kernel->getLogDir(); + } + + $this->assertTrue( + is_writable($logDir), + sprintf('Symfony log directory is not writable: %s', $logDir) + ); + } + + /** + * Asserts that the minimal Symfony project structure exists and is usable. + * + * ```php + * seeProjectStructureIsSane(); + * ``` + */ + public function seeProjectStructureIsSane(): void + { + $root = $this->getProjectDir(); + foreach (['config', 'src', 'public', 'var'] as $dir) { + $this->assertTrue(is_dir($root . $dir), sprintf('Directory "%s" is missing.', $dir)); + } + + foreach (['var/cache', 'var/log'] as $dir) { + $this->assertTrue(is_dir($root . $dir), sprintf('Directory "%s" is missing.', $dir)); + $this->assertTrue(is_writable($root . $dir), sprintf('Directory "%s" is not writable.', $dir)); + } + + $this->assertFileExists($root . 'config/bundles.php', 'Missing config/bundles.php file.'); + + $bin = $root . 'bin/console'; + $this->assertTrue(is_file($bin), 'bin/console is missing.'); + if (strncasecmp(PHP_OS, 'WIN', 3) !== 0) { + $this->assertTrue(is_executable($bin), 'bin/console is not executable.'); + } + } + + /** + * Asserts that all keys in example env file(s) exist either in the provided env file(s) OR as OS env vars. + * This validates presence only, not values. It also considers common local/test files if present. + * + * ```php + * assertEnvFileIsSynchronized(); + * ``` + * + * @param non-empty-string $envPath + * @param non-empty-string $examplePath + * @param list $additionalEnvPaths + */ + public function assertEnvFileIsSynchronized(string $envPath = '.env', string $examplePath = '.env.example', array $additionalEnvPaths = []): void + { + $projectDir = $this->getProjectDir(); + + $candidateExtras = ['.env.local', '.env.test', '.env.test.local']; + foreach ($candidateExtras as $extra) { + if (file_exists($projectDir . $extra)) { + $additionalEnvPaths[] = $extra; + } + } + + $exampleContent = @file_get_contents($projectDir . $examplePath) ?: ''; + $envContent = @file_get_contents($projectDir . $envPath) ?: ''; + + foreach ($additionalEnvPaths as $extra) { + $envContent .= "\n" . (@file_get_contents($projectDir . $extra) ?: ''); + } + + $exampleKeys = $this->extractEnvKeys($exampleContent); + $envKeys = $this->extractEnvKeys($envContent); + + $osKeys = array_keys($_ENV + $_SERVER); + $present = array_flip(array_merge($envKeys, $osKeys)); + + $missing = []; + foreach ($exampleKeys as $key) { + if (!isset($present[$key])) { + $missing[] = $key; + } + } + + $this->assertEmpty( + $missing, + sprintf('Missing variables from %s (not found across %s nor as OS envs): %s', $examplePath, implode(', ', array_merge([$envPath], $additionalEnvPaths)), implode(', ', $missing)) + ); + } + + /** + * Asserts that a specific bundle is enabled in the Kernel. + * + * ```php + * seeBundleIsEnabled(Acme\\AcmeBundle::class); + * ``` + * + * @param class-string $bundleClass The Fully Qualified Class Name of the bundle. + */ + public function seeBundleIsEnabled(string $bundleClass): void + { + $bundles = $this->kernel->getBundles(); + $found = false; + foreach ($bundles as $bundle) { + if ($bundle instanceof $bundleClass || $bundle::class === $bundleClass) { + $found = true; + break; + } + } + + $this->assertTrue( + $found, + sprintf('Bundle "%s" is not enabled in the Kernel. Check config/bundles.php.', $bundleClass) + ); + } + + /** + * Asserts that an asset manifest file exists, checking for Webpack Encore or AssetMapper. + * + * ```php + * seeAssetManifestExists(); + * ``` + */ + public function seeAssetManifestExists(): void + { + $projectDir = $this->getProjectDir(); + $encoreManifest = $projectDir . 'public/build/manifest.json'; + $mapperManifest = $projectDir . 'public/assets/manifest.json'; + $encoreEntrypoints = $projectDir . 'public/build/entrypoints.json'; + + if (is_readable($encoreManifest) && is_readable($encoreEntrypoints)) { + $this->assertJson((string) file_get_contents($encoreManifest), 'Webpack Encore manifest.json is not valid JSON.'); + $this->assertJson((string) file_get_contents($encoreEntrypoints), 'Webpack Encore entrypoints.json is not valid JSON.'); + return; + } + + if (is_readable($mapperManifest)) { + $this->assertJson((string) file_get_contents($mapperManifest), 'AssetMapper manifest.json is not valid JSON.'); + return; + } + + Assert::fail('No asset manifest file found. Checked for Webpack Encore (public/build/manifest.json) and AssetMapper (public/assets/manifest.json).'); + } + + /** + * Asserts the Kernel charset matches the expected value. + * + * ```php + * seeKernelCharsetIs('UTF-8'); + * ``` + */ + public function seeKernelCharsetIs(string $expected): void + { + $charset = $this->kernel->getCharset(); + $this->assertSame($expected, $charset, sprintf('Kernel charset is "%s" but expected "%s".', $charset, $expected)); + } + + /** + * Helper to get the project's root directory. + */ + protected function getProjectDir(): string + { + return $this->kernel->getProjectDir() . '/'; + } + + /** + * Extracts variable keys from the content of a .env file. + * + * @return list + */ + private function extractEnvKeys(string $content): array + { + $keys = []; + if (preg_match_all('/^(?!#)\s*([a-zA-Z_][a-zA-Z0-9_]*)=/m', $content, $matches)) { + $keys = $matches[1]; + } + return $keys; + } +} diff --git a/src/Codeception/Module/Symfony/SecurityAssertionsTrait.php b/src/Codeception/Module/Symfony/SecurityAssertionsTrait.php index 3802598..d10d99e 100644 --- a/src/Codeception/Module/Symfony/SecurityAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/SecurityAssertionsTrait.php @@ -6,11 +6,16 @@ use PHPUnit\Framework\Assert; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; +use Throwable; +use function array_keys; +use function array_unique; +use function array_values; use function sprintf; trait SecurityAssertionsTrait @@ -147,6 +152,100 @@ public function seeUserPasswordDoesNotNeedRehash(?UserInterface $user = null): v $this->assertFalse($hasher->needsRehash($userToValidate), 'User password needs rehash.'); } + /** + * Asserts that a security firewall is configured and active. + * + * ```php + * seeFirewallIsActive('main'); + * ``` + */ + public function seeFirewallIsActive(string $firewallName): void + { + $container = $this->_getContainer(); + + if ($container->hasParameter('security.firewalls')) { + /** @var list $firewalls */ + $firewalls = $container->getParameter('security.firewalls'); + $this->assertContains( + $firewallName, + $firewalls, + sprintf('Firewall "%s" is not configured. Check your security.yaml.', $firewallName) + ); + + return; + } + + $contextId = 'security.firewall.map.context.' . $firewallName; + $this->assertTrue( + $container->has($contextId), + sprintf('Firewall "%s" context was not found (checked "%s").', $firewallName, $contextId) + ); + } + + /** + * Asserts that a role is present either as a key of the role hierarchy or among any inherited roles. + * Skips when role hierarchy is not configured. + * + * ```php + * seeRoleInHierarchy('ROLE_ADMIN'); + * ``` + */ + public function seeRoleInHierarchy(string $role): void + { + $container = $this->_getContainer(); + if (!$container->hasParameter('security.role_hierarchy.roles')) { + Assert::markTestSkipped('Role hierarchy is not configured; skipping role hierarchy assertion.'); + } + + /** @var array> $hierarchy */ + $hierarchy = $container->getParameter('security.role_hierarchy.roles'); + + $all = array_keys($hierarchy); + foreach ($hierarchy as $children) { + foreach ($children as $child) { + $all[] = $child; + } + } + $all = array_values(array_unique($all)); + + $this->assertContains( + $role, + $all, + sprintf('Role "%s" was not found in the role hierarchy. Check security.yaml.', $role) + ); + } + + /** + * Asserts that a secret stored in Symfony's vault can be resolved. + * + * ```php + * seeSecretCanBeResolved('DATABASE_PASSWORD'); + * ``` + * + * @param non-empty-string $secretName The name of the secret (e.g., 'DATABASE_PASSWORD'). + */ + public function seeSecretCanBeResolved(string $secretName): void + { + try { + /** @var ContainerBagInterface $params */ + $params = $this->grabService('parameter_bag'); + $value = $params->get(sprintf('env(resolve:%s)', $secretName)); + + Assert::assertIsString($value, sprintf('Secret "%s" could be resolved but did not return a string.', $secretName)); + } catch (Throwable $e) { + Assert::fail( + sprintf( + 'Failed to resolve secret "%s". Check your vault and decryption keys. Error: %s', + $secretName, + $e->getMessage() + ) + ); + } + } + private function getAuthenticatedUser(): UserInterface { $user = $this->grabSecurityService()->getUser(); diff --git a/src/Codeception/Module/Symfony/SessionAssertionsTrait.php b/src/Codeception/Module/Symfony/SessionAssertionsTrait.php index 7052894..2ce18cb 100644 --- a/src/Codeception/Module/Symfony/SessionAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/SessionAssertionsTrait.php @@ -5,6 +5,7 @@ namespace Codeception\Module\Symfony; use InvalidArgumentException; +use PHPUnit\Framework\Assert; use Symfony\Component\BrowserKit\Cookie; use Symfony\Component\HttpFoundation\Session\SessionFactoryInterface; use Symfony\Component\HttpFoundation\Session\SessionInterface; @@ -20,8 +21,14 @@ use function class_exists; use function in_array; +use function ini_get; +use function is_array; +use function is_dir; use function is_int; +use function is_string; +use function is_writable; use function serialize; +use function sprintf; trait SessionAssertionsTrait { @@ -172,6 +179,58 @@ public function seeSessionHasValues(array $bindings): void } } + /** + * Asserts that the session save path is writable when using file-based sessions. + * Skips when session storage is not file-based. + * + * ```php + * seeSessionSavePathIsWritable(); + * ``` + */ + public function seeSessionSavePathIsWritable(): void + { + $container = $this->_getContainer(); + + $isFileBased = false; + if ($container->has('session.storage.factory.native_file') || $container->has('session.handler.native_file')) { + $isFileBased = true; + } + + $iniHandler = (string) (ini_get('session.save_handler') ?: ''); + if ($iniHandler === 'files') { + $isFileBased = true; + } + + if (!$isFileBased) { + $this->markTestSkipped('Session storage is not file-based; skipping save path writability check.'); + } + + $savePath = null; + + if ($container->hasParameter('session.storage.options')) { + $options = $container->getParameter('session.storage.options'); + if (is_array($options) && isset($options['save_path']) && is_string($options['save_path']) && $options['save_path'] !== '') { + $savePath = $options['save_path']; + } + } + + if (!$savePath) { + $ini = (string) (ini_get('session.save_path') ?: ''); + if ($ini !== '') { + $savePath = $ini; + } + } + + if (!$savePath) { + $env = $this->kernel->getEnvironment(); + $savePath = $this->kernel->getProjectDir() . '/var/sessions/' . $env; + } + + $this->assertTrue(is_dir($savePath), sprintf('Session save path is not a directory: %s', $savePath)); + $this->assertTrue(is_writable($savePath), sprintf('Session save path is not writable: %s', $savePath)); + } + protected function getTokenStorage(): TokenStorageInterface { /** @var TokenStorageInterface $storage */