fix(tests): isolate databases in memory and reject persistent connections
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
colors="true"
|
||||
>
|
||||
<testsuites>
|
||||
@@ -19,7 +19,9 @@
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing" force="true"/>
|
||||
<env name="DB_DATABASE" value="shopit_test" force="true"/>
|
||||
<env name="DB_CONNECTION" value="sqlite" force="true"/>
|
||||
<env name="DB_DATABASE" value=":memory:" force="true"/>
|
||||
<env name="DB_URL" value="null" force="true"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="APP_CONFIG_CACHE" value="bootstrap/cache/phpunit-config.php"/>
|
||||
<env name="APP_EVENTS_CACHE" value="bootstrap/cache/phpunit-events.php"/>
|
||||
|
||||
17
tests/README.md
Normal file
17
tests/README.md
Normal file
@@ -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.
|
||||
31
tests/Support/InMemoryConnectionFactory.php
Normal file
31
tests/Support/InMemoryConnectionFactory.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
use Illuminate\Database\Connectors\ConnectionFactory;
|
||||
use RuntimeException;
|
||||
|
||||
final class InMemoryConnectionFactory extends ConnectionFactory
|
||||
{
|
||||
public static function assertSafe(array $config): void
|
||||
{
|
||||
// Reject URLs and alternate endpoints rather than trusting a database name.
|
||||
if (($config['driver'] ?? null) !== 'sqlite'
|
||||
|| ($config['database'] ?? null) !== ':memory:'
|
||||
|| ! empty($config['url'])
|
||||
|| array_key_exists('read', $config)
|
||||
|| array_key_exists('write', $config)
|
||||
|| array_key_exists('direct', $config)) {
|
||||
throw new RuntimeException(
|
||||
'Unsafe test connection. Tests may only use SQLite :memory: without URLs or alternate endpoints.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function make(array $config, $name = null)
|
||||
{
|
||||
self::assertSafe($config);
|
||||
|
||||
return parent::make($config, $name);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Foundation\Bootstrap\LoadConfiguration;
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
use RuntimeException;
|
||||
use Tests\Support\InMemoryConnectionFactory;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
@@ -13,19 +16,28 @@ abstract class TestCase extends BaseTestCase
|
||||
*/
|
||||
public function createApplication(): Application
|
||||
{
|
||||
$app = parent::createApplication();
|
||||
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
|
||||
16
tests/bootstrap.php
Normal file
16
tests/bootstrap.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
// Set every environment source before Laravel or Dotenv can read the local .env.
|
||||
foreach ([
|
||||
'APP_ENV' => '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';
|
||||
80
tests/verify-database-safety.php
Normal file
80
tests/verify-database-safety.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
// Standalone safety check: no test lifecycle, migrations, queries, or PDO connections.
|
||||
putenv('DB_CONNECTION=mysql');
|
||||
$_ENV['DB_DATABASE'] = 'shopit';
|
||||
$_SERVER['DB_URL'] = 'mysql://localhost/shopit';
|
||||
require __DIR__.'/bootstrap.php';
|
||||
|
||||
use Illuminate\Container\Container;
|
||||
use Tests\Support\InMemoryConnectionFactory;
|
||||
|
||||
$safe = ['driver' => '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";
|
||||
Reference in New Issue
Block a user