fix(tests): isolate databases in memory and reject persistent connections
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# Project Conventions
|
# 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
|
## Architecture
|
||||||
|
|
||||||
This project uses a domain-oriented structure under `app/Domains`.
|
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"
|
"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": [
|
"test": [
|
||||||
"@php artisan config:clear --ansi @no_additional_args",
|
"@php vendor/phpunit/phpunit/phpunit"
|
||||||
"@php artisan test"
|
|
||||||
],
|
],
|
||||||
"post-autoload-dump": [
|
"post-autoload-dump": [
|
||||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
bootstrap="vendor/autoload.php"
|
bootstrap="tests/bootstrap.php"
|
||||||
colors="true"
|
colors="true"
|
||||||
>
|
>
|
||||||
<testsuites>
|
<testsuites>
|
||||||
@@ -19,7 +19,9 @@
|
|||||||
</source>
|
</source>
|
||||||
<php>
|
<php>
|
||||||
<env name="APP_ENV" value="testing" force="true"/>
|
<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_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<env name="APP_CONFIG_CACHE" value="bootstrap/cache/phpunit-config.php"/>
|
<env name="APP_CONFIG_CACHE" value="bootstrap/cache/phpunit-config.php"/>
|
||||||
<env name="APP_EVENTS_CACHE" value="bootstrap/cache/phpunit-events.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;
|
namespace Tests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Contracts\Console\Kernel;
|
||||||
|
use Illuminate\Foundation\Bootstrap\LoadConfiguration;
|
||||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
use Tests\Support\InMemoryConnectionFactory;
|
||||||
|
|
||||||
abstract class TestCase extends BaseTestCase
|
abstract class TestCase extends BaseTestCase
|
||||||
{
|
{
|
||||||
@@ -13,19 +16,28 @@ abstract class TestCase extends BaseTestCase
|
|||||||
*/
|
*/
|
||||||
public function createApplication(): Application
|
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(
|
if ($app->configurationIsCached()) {
|
||||||
'database.connections.'.$app['config']->get('database.default').'.database'
|
throw new RuntimeException('Tests refuse cached configuration. Remove the test config cache before retrying.');
|
||||||
);
|
|
||||||
|
|
||||||
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)'
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
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