fix(tests): isolate databases in memory and reject persistent connections

This commit is contained in:
2026-09-14 12:35:34 -03:00
parent 28ab6c9ae4
commit 289ceba3af
8 changed files with 177 additions and 14 deletions

View File

@@ -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`.

View File

@@ -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",

View File

@@ -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
View 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.

View 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);
}
}

View File

@@ -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
View 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';

View 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";