diff --git a/composer.json b/composer.json index 4c270e4..3ca0d68 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "doctrine/doctrine-bundle": "^2.10.0|^3.0", "doctrine/orm": "^2.15|^3.0", "friendsofphp/php-cs-fixer": "^3.34", - "laravel/framework": "^10.0|^11.0", + "laravel/framework": "^10.0|^11.0|^12.0|^13.0", "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^10.4", "symfony/dependency-injection": "^6.0|^7.0|^8.0", diff --git a/tests/Unit/Bridge/Laravel/DbToolsServiceProviderTest.php b/tests/Unit/Bridge/Laravel/DbToolsServiceProviderTest.php new file mode 100644 index 0000000..dd627ed --- /dev/null +++ b/tests/Unit/Bridge/Laravel/DbToolsServiceProviderTest.php @@ -0,0 +1,341 @@ +assertTrue( + $app->bound(DatabaseSessionRegistry::class), + 'DatabaseSessionRegistry should be bound' + ); + $this->assertTrue( + $app->bound(\MakinaCorpus\DbToolsBundle\Configuration\ConfigurationRegistry::class), + 'ConfigurationRegistry should be bound' + ); + $this->assertTrue( + $app->bound(\MakinaCorpus\DbToolsBundle\Storage\Storage::class), + 'Storage should be bound' + ); + $this->assertTrue( + $app->bound(\MakinaCorpus\DbToolsBundle\Anonymization\Anonymizer\AnonymizerRegistry::class), + 'AnonymizerRegistry should be bound' + ); + $this->assertTrue( + $app->bound(\MakinaCorpus\DbToolsBundle\Backupper\BackupperFactory::class), + 'BackupperFactory should be bound' + ); + $this->assertTrue( + $app->bound(\MakinaCorpus\DbToolsBundle\Restorer\RestorerFactory::class), + 'RestorerFactory should be bound' + ); + } + + /** + * Verify the registry can resolve a database session for the SQLite connection. + */ + public function testRegistryResolvesSessionForSqliteConnection(): void + { + /** @var DatabaseSessionRegistry $registry */ + $registry = self::$app->make(DatabaseSessionRegistry::class); + + $session = $registry->getDatabaseSession('sqlite'); + $this->assertNotNull($session); + $this->assertInstanceOf( + \MakinaCorpus\QueryBuilder\DatabaseSession::class, + $session + ); + } + + /** + * Verify seeded data exists and looks correct before anonymization. + */ + public function testDatabaseContainsSeededUsersAndPosts(): void + { + $pdo = new \PDO('sqlite:' . self::$dbPath); + + $users = $pdo->query('SELECT * FROM users ORDER BY id')->fetchAll(\PDO::FETCH_ASSOC); + $this->assertCount(3, $users, 'Expected 3 seeded users'); + $this->assertSame('Alice Smith', $users[0]['name']); + $this->assertSame('alice@example.com', $users[0]['email']); + $this->assertSame('Bob Jones', $users[1]['name']); + $this->assertSame('Charlie Brown', $users[2]['name']); + + $posts = $pdo->query('SELECT * FROM posts ORDER BY id')->fetchAll(\PDO::FETCH_ASSOC); + $this->assertCount(3, $posts, 'Expected 3 seeded posts'); + $this->assertSame('Secret personal content about Alice', $posts[0]['body']); + } + + /** + * Verify that anonymization correctly mutates targeted columns. + */ + public function testAnonymizesUserNameAndEmail(): void + { + /** @var DatabaseSessionRegistry $registry */ + $registry = self::$app->make(DatabaseSessionRegistry::class); + $session = $registry->getDatabaseSession('sqlite'); + + $config = new AnonymizationConfig(); + $config->add(new AnonymizerConfig('users', 'name', 'string', new Options([ + 'sample' => ['Anon User', 'Hidden Person', 'Redacted Name'], + ]))); + $config->add(new AnonymizerConfig('users', 'email', 'email', new Options([]))); + + $anonymizator = new Anonymizator($session, new AnonymizerRegistry(), $config); + $anonymizator->addAnonymizerIdColumn('users'); + $anonymizator->anonymize(); + + $pdo = new \PDO('sqlite:' . self::$dbPath); + $rows = $pdo->query('SELECT name, email FROM users ORDER BY id')->fetchAll(\PDO::FETCH_ASSOC); + + $this->assertCount(3, $rows); + + foreach ($rows as $row) { + // Names must have been replaced by something from the sample list + $this->assertContains( + $row['name'], + ['Anon User', 'Hidden Person', 'Redacted Name'], + 'Name should be one of the sample values' + ); + // Emails should still look like emails + $this->assertStringContainsString('@', $row['email'], 'Anonymized email should contain @'); + // Emails must not be the originals + $this->assertNotContains($row['email'], [ + 'alice@example.com', + 'bob@example.com', + 'charlie@example.com', + ], 'Original email should have been replaced'); + } + } + + /** + * Verify that posts.body is anonymized with lorem ipsum text. + */ + public function testAnonymizesPostBody(): void + { + /** @var DatabaseSessionRegistry $registry */ + $registry = self::$app->make(DatabaseSessionRegistry::class); + $session = $registry->getDatabaseSession('sqlite'); + + $config = new AnonymizationConfig(); + $config->add(new AnonymizerConfig('posts', 'body', 'lorem', new Options([]))); + + $anonymizator = new Anonymizator($session, new AnonymizerRegistry(), $config); + $anonymizator->addAnonymizerIdColumn('posts'); + $anonymizator->anonymize(); + + $pdo = new \PDO('sqlite:' . self::$dbPath); + $rows = $pdo->query('SELECT body FROM posts ORDER BY id')->fetchAll(\PDO::FETCH_ASSOC); + + $this->assertCount(3, $rows); + foreach ($rows as $row) { + $this->assertNotEmpty($row['body'], 'Body should not be empty after anonymization'); + $this->assertNotSame( + 'Secret personal content about Alice', + $row['body'], + 'Body should have been anonymized' + ); + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Boot a minimal Laravel application with an in-memory config. + */ + private static function bootLaravelApp(string $dbPath): Application + { + $basePath = \sys_get_temp_dir() . '/laravel_dbtoolstest_' . \uniqid(); + + foreach ([ + '/storage/framework/sessions', + '/storage/framework/views', + '/storage/framework/cache', + '/storage/logs', + '/bootstrap/cache', + '/config', + ] as $dir) { + \mkdir($basePath . $dir, 0755, true); + } + + $app = new Application($basePath); + + $app->singleton( + \Illuminate\Contracts\Http\Kernel::class, + \Illuminate\Foundation\Http\Kernel::class, + ); + $app->singleton( + \Illuminate\Contracts\Console\Kernel::class, + \Illuminate\Foundation\Console\Kernel::class, + ); + $app->singleton( + \Illuminate\Contracts\Debug\ExceptionHandler::class, + \Illuminate\Foundation\Exceptions\Handler::class, + ); + + // Provide all config in-process — no files needed + $app->singleton('config', function () use ($dbPath, $basePath) { + return new \Illuminate\Config\Repository([ + 'app' => [ + 'name' => 'DbToolsTestApp', + 'env' => 'testing', + 'key' => 'base64:' . \base64_encode(\random_bytes(32)), + 'providers' => [], + ], + 'database' => [ + 'default' => 'sqlite', + 'connections' => [ + 'sqlite' => [ + 'driver' => 'sqlite', + 'database' => $dbPath, + 'prefix' => '', + ], + ], + ], + 'logging' => [ + 'default' => 'single', + 'channels' => [ + 'single' => [ + 'driver' => 'single', + 'path' => $basePath . '/storage/logs/laravel.log', + ], + ], + ], + 'db-tools' => [ + 'storage_directory' => $basePath . '/storage/db_tools', + 'anonymizer_paths' => [], + 'anonymization' => [], + 'anonymization_files' => [], + 'connections' => [], + ], + ]); + }); + + $app->register(\Illuminate\Database\DatabaseServiceProvider::class); + $app->register(\Illuminate\Log\LogServiceProvider::class); + $app->register(DbToolsServiceProvider::class); + + Facade::setFacadeApplication($app); + $app->boot(); + + Container::setInstance($app); + Application::setInstance($app); + + return $app; + } + + /** + * Create the users and posts tables — mirroring real Laravel Eloquent models. + */ + private static function createSchema(): void + { + $pdo = new \PDO('sqlite:' . self::$dbPath); + $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + + $pdo->exec('PRAGMA foreign_keys = ON'); + + $pdo->exec(<<<'SQL' + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + created_at DATETIME, + updated_at DATETIME + ) + SQL); + + $pdo->exec(<<<'SQL' + CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + created_at DATETIME, + updated_at DATETIME, + FOREIGN KEY (user_id) REFERENCES users(id) + ) + SQL); + } + + /** + * Seed realistic data. + */ + private static function seedData(): void + { + $pdo = new \PDO('sqlite:' . self::$dbPath); + $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + + $now = \date('Y-m-d H:i:s'); + + $pdo->exec(<<exec(<<