From 289ceba3af37f49009a9334f400baf0385c2e9de Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 12:35:34 -0300 Subject: [PATCH] fix(tests): isolate databases in memory and reject persistent connections --- AGENTS.md | 6 ++ composer.json | 3 +- phpunit.xml | 6 +- tests/README.md | 17 +++++ tests/Support/InMemoryConnectionFactory.php | 31 ++++++++ tests/TestCase.php | 32 ++++++--- tests/bootstrap.php | 16 +++++ tests/verify-database-safety.php | 80 +++++++++++++++++++++ 8 files changed, 177 insertions(+), 14 deletions(-) create mode 100644 tests/README.md create mode 100644 tests/Support/InMemoryConnectionFactory.php create mode 100644 tests/bootstrap.php create mode 100644 tests/verify-database-safety.php diff --git a/AGENTS.md b/AGENTS.md index 011848b..075fcaa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,11 @@ # Project Conventions +## Test database safety + +- Tests must use SQLite `:memory:` through `tests/bootstrap.php` and `Tests\TestCase`. +- Never run tests, `migrate:fresh`, `migrate:refresh`, or `db:wipe` against a persistent database, including the developer's `shopit` database. +- Never bypass the connection safety guard to resolve test failures. Use `php tests/verify-database-safety.php` to verify isolation without queries or migrations. + ## Architecture This project uses a domain-oriented structure under `app/Domains`. diff --git a/composer.json b/composer.json index 78ca5d7..d806e5f 100644 --- a/composer.json +++ b/composer.json @@ -52,8 +52,7 @@ "npx concurrently -c \"#93c5fd,#c4b5fd,#a7f3d0,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --queue=emails,default --tries=1 --timeout=0\" \"php artisan schedule:work\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,scheduler,logs,vite --kill-others" ], "test": [ - "@php artisan config:clear --ansi @no_additional_args", - "@php artisan test" + "@php vendor/phpunit/phpunit/phpunit" ], "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", diff --git a/phpunit.xml b/phpunit.xml index 0bce1ba..9963a5e 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,7 +1,7 @@ @@ -19,7 +19,9 @@ - + + + diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..d22d58f --- /dev/null +++ b/tests/README.md @@ -0,0 +1,17 @@ +# Database isolation + +Run the suite with `composer test` or `vendor/bin/phpunit`. Both use +`tests/bootstrap.php`, which forces SQLite `:memory:` in all environment sources. +Tests never need a MySQL test database or the local database credentials. + +`Tests\TestCase` rejects cached configuration and validates the default connection +before application providers boot. Its connection factory also rejects persistent +databases, URLs, and alternate endpoints for named or dynamically built connections. +Tests that need Laravel must extend this base class. Do not bypass these guards to +make a failing test pass; adapt database-specific tests to SQLite or use a separately +designed disposable database workflow. + +`php tests/verify-database-safety.php` checks the guard and application wiring without +running test setup, queries, migrations, or opening PDO connections. + +The suite does not validate MySQL-specific behavior when using SQLite. diff --git a/tests/Support/InMemoryConnectionFactory.php b/tests/Support/InMemoryConnectionFactory.php new file mode 100644 index 0000000..71b7c18 --- /dev/null +++ b/tests/Support/InMemoryConnectionFactory.php @@ -0,0 +1,31 @@ +traitsUsedByTest = class_uses_recursive(static::class); - $database = (string) $app['config']->get( - 'database.connections.'.$app['config']->get('database.default').'.database' - ); - - if (! preg_match('/^shopit_(?:test|testing)(?:_\d+)?$/', $database)) { - throw new RuntimeException(sprintf( - 'Refusing to run tests against database [%s]. Use [shopit_test] or [shopit_testing].', - $database !== '' ? $database : '(empty)' - )); + if ($app->configurationIsCached()) { + throw new RuntimeException('Tests refuse cached configuration. Remove the test config cache before retrying.'); } + // Validate before providers boot or RefreshDatabase can run migrations. + $app->afterBootstrapping(LoadConfiguration::class, function (Application $app): void { + if (! $app->environment('testing')) { + throw new RuntimeException('Tests require APP_ENV=testing.'); + } + + InMemoryConnectionFactory::assertSafe((array) $app['config']->get( + 'database.connections.'.$app['config']->get('database.default') + )); + }); + + // Also guard named/dynamic connections and changes made by individual tests. + $app->extend('db.factory', fn () => new InMemoryConnectionFactory($app)); + $app->make(Kernel::class)->bootstrap(); + return $app; } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..913086d --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,16 @@ + 'testing', + 'DB_CONNECTION' => 'sqlite', + 'DB_DATABASE' => ':memory:', + 'DB_URL' => 'null', + 'APP_CONFIG_CACHE' => __DIR__.'/../bootstrap/cache/phpunit-config.php', +] as $key => $value) { + putenv($key.'='.$value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; +} + +require __DIR__.'/../vendor/autoload.php'; diff --git a/tests/verify-database-safety.php b/tests/verify-database-safety.php new file mode 100644 index 0000000..2a2e1e8 --- /dev/null +++ b/tests/verify-database-safety.php @@ -0,0 +1,80 @@ + 'sqlite', 'database' => ':memory:']; +$unsafe = [ + [], + ['driver' => 'mysql', 'database' => 'shopit'], + ['driver' => 'mysql', 'database' => 'shopit_test'], + ['driver' => 'sqlite', 'database' => 'database/database.sqlite'], + ['driver' => 'sqlite', 'database' => 'shopit_test'], + array_merge($safe, ['url' => 'mysql://localhost/shopit']), + array_merge($safe, ['read' => ['database' => 'shopit']]), + array_merge($safe, ['write' => ['database' => 'shopit']]), + array_merge($safe, ['direct' => ['database' => 'shopit']]), +]; +$factory = new InMemoryConnectionFactory(new Container); +foreach ($unsafe as $config) { + try { + $factory->make($config); + } catch (RuntimeException) { + continue; + } + + throw new RuntimeException('Unsafe connection was accepted.'); +} + +$connection = $factory->make($safe); +if (! $connection->getRawPdo() instanceof Closure) { + throw new RuntimeException('Verification must not open a PDO connection.'); +} + +$case = new class('safetyCheck') extends Tests\TestCase {}; +$app = $case->createApplication(); +if (! $app['db.factory'] instanceof InMemoryConnectionFactory + || $app['config']->get('database.default') !== 'sqlite' + || ! $app['db']->connection()->getRawPdo() instanceof Closure) { + throw new RuntimeException('Application database isolation is not active.'); +} + +foreach (['mysql', 'pgsql', 'mariadb', 'sqlsrv'] as $name) { + try { + $app['db']->connection($name); + } catch (RuntimeException) { + continue; + } + + throw new RuntimeException('A persistent application connection was accepted.'); +} + +// Include URL overrides resolved by Laravel and dynamically built connections. +foreach ([ + ['driver' => 'mysql', 'database' => 'shopit'], + array_merge($safe, ['url' => 'mysql://localhost/shopit']), + array_merge($safe, ['url' => 'sqlite:///database/database.sqlite']), +] as $config) { + $app['config']->set('database.connections.unsafe', $config); + + foreach ([ + fn () => $app['db']->connection('unsafe'), + fn () => $app['db']->build($config), + ] as $connect) { + try { + $connect(); + } catch (RuntimeException) { + continue; + } + + throw new LogicException('A dynamically configured persistent connection was accepted.'); + } +} + +echo "Database safety verified: unsafe connections rejected; no PDO connections or migrations executed.\n";