0% прочитано

Чому .env.testing недостатньо, щоб захистити локальну базу під час Laravel-тестів

Після втрати локальної бази я перевірила тестову інфраструктуру Laravel і побачила дві незалежні проблеми: RefreshDatabase міг обійти власний guard, а test environment використовував MySQL-користувача з надто широкими правами. Надійніший захист виявився двошаровим: framework guard плюс окремий test database user.

11 жовтня 2026 р. 11 хв читанняЛаравель

Під час роботи над великим Laravel-проєктом моя локальна MySQL-база раптово залишилася без основних робочих даних.

На той момент я працювала з backend-тестами, де використовувався RefreshDatabase.

Спочатку я не знала, що саме стало причиною. І це важливо: у такій ситуації дуже легко побачити один підозрілий рядок і одразу оголосити його винним.

Я пішла іншим шляхом: спочатку відновлення даних, потім перевірка тестової інфраструктури, а після цього побудова захисту так, щоб одна помилка більше не могла знищити іншу базу.

Я не називаю знайдений шлях стовідсотково доведеною forensic-причиною інциденту. Але він був реальним механізмом, через який існуючий захист міг не спрацювати.

Спочатку я перевірила не базу, а весь тестовий запуск

Пошук усіх місць, які можуть впливати на тестову базу
I checked: .env.testingphpunit.xmltests/TestCase.phptests/Pest.php and searched for: RefreshDatabasemigrate:freshdb:wipeArtisan::call(...)destructive SQL

Однієї перевірки .env.testing недостатньо.

Laravel-тести отримують конфігурацію не з одного файла. На результат можуть впливати phpunit.xml, базовий Tests\TestCase, Pest bootstrap, trait-и та окремі тестові класи.

Тому я шукала не тільки неправильну назву бази, а й усі шляхи, які могли викликати destructive database operation.

У базовому TestCase уже був правильний захист

Спрощений приклад захищеного refreshDatabase()
protected function refreshDatabase(): void{    if (! app()->environment('testing')) {        throw new RuntimeException(            'Database refresh is allowed only in testing.',        );    }     $database = DB::connection()        ->getDatabaseName();     if ($database !== 'app_test') {        throw new RuntimeException(            'Refusing to refresh a non-test database.',        );    }

Ідея правильна: перед очищенням база має пройти дві незалежні перевірки.

По-перше, застосунок повинен реально працювати в середовищі testing. По-друге, активна база має бути саме тестовою.

Проблема виявилася не в самій перевірці, а в тому, чи всі тести гарантовано проходять через цей метод.

RefreshDatabase був підключений у кількох місцях

Небезпечне дублювання trait у Pest bootstrap
// tests/Pest.php uses(    Tests\TestCase::class,    Illuminate\Foundation\Testing\RefreshDatabase::class,)->in('Feature');
Повторне локальне підключення
// Individual test file uses(    Illuminate\Foundation\Testing\RefreshDatabase::class,);

RefreshDatabase уже був частиною базового Tests\TestCase, але додатково підключався глобально через Pest і ще в окремих Feature/Integration тестах.

Це створювало небезпечну неоднозначність у тестовій інфраструктурі.

Pest-generated test class міг отримати trait безпосередньо, і тоді власний захищений refreshDatabase() з базового класу вже не був єдиною очевидною точкою виконання.

Я відділяю доведений факт від правдоподібного шляху відмови

Факт був простий: локальні робочі дані зникли.

Другий факт: тестова інфраструктура містила дубльоване підключення RefreshDatabase, яке могло обійти точку з власним guard.

Але без повного журналу виконання я не можу чесно стверджувати, що саме цей шлях гарантовано викликав інцидент.

Для захисту системи цього й не потрібно. Якщо я знаходжу реальний шлях обходу safety guard, його варто закрити незалежно від того, чи він був єдиною причиною минулого інциденту.

Друга проблема була нижче за Laravel - у правах MySQL

Один користувач мав доступ до кількох баз
Development environment    |    +--> app_local    |    +--> app_test    |    +--> app_recovery Same MySQL userwith broad privileges

.env.testing вказував на окрему test database, але використовував того самого MySQL-користувача, що й звичайне локальне середовище.

Цей користувач мав достатньо прав, щоб працювати не лише з тестовою базою.

Тобто якщо framework configuration помилялася і підключення раптом вказувало не туди, MySQL не мав другого запобіжника.

Саме тут я побачила головну слабкість моделі: назва бази була іншою, але authority залишалася спільною.

.env.testing є конфігурацією, а не межею повноважень

Два незалежні рівні захисту
Layer 1 Laravel configurationexpects:app_test  Layer 2 MySQL privilegesallow:app_test only  If layer 1 fails,layer 2 still refuses

.env.testing говорить Laravel, яку базу він повинен використовувати.

Але цей файл не може заборонити MySQL-користувачу відкрити іншу базу, якщо в користувача вже є такі права.

Тому я більше не сприймаю окрему назву бази як достатню ізоляцію.

Конфігурація повинна бути правильною, але database server має окремо обмежувати те, що тестовий користувач фізично може зробити.

Відновлення почала з окремої recovery-бази

Не працюю безпосередньо з єдиною копією
damaged local state        |        vseparate recovery database        |        vinspect what is available        |        vchoose recovery source        |        vrestore local working database

Першою метою було не "якомога швидше залити щось назад", а не погіршити ситуацію.

Тому для відновлення я використала окрему recovery-базу і спочатку перевіряла, що реально залишилося локально.

Це дозволяє аналізувати копію, не змінюючи єдиний доступний стан під час пошуку рішення.

Production-копія стала вимушеним джерелом відновлення

Коли стало зрозуміло, що актуальної повної локальної копії немає, для відновлення довелося використати копію production-бази.

Я не вважаю це нормальним щоденним способом синхронізації development environment.

Це був recovery-крок після втрати локальних даних.

Такі копії містять реальні дані, тому з ними треба працювати як із чутливим матеріалом, а не як зі звичайним fixture.

Після перенесення production data я нейтралізувала облікові дані

Відновлення не завершується самим імпортом
production dump     |     vlocal restore     |     vreplace passwordsdisable sensitive tokensremove production-only credentials     |     vsafe local working copy

Після відновлення локальної бази я змінила паролі, щоб production credentials не залишалися придатними для використання в development environment.

Той самий принцип стосується API tokens, секретів, інтеграційних ключів та інших даних, які не повинні працювати поза production.

У публічній статті я не показую реальні адреси, хости, користувачів, паролі або інші production identifiers.

Перший захисний шар - один власник RefreshDatabase

Pest використовує TestCase без повторного trait
// tests/Pest.php uses(    Tests\TestCase::class,)->in('Feature');

Після інциденту я залишила RefreshDatabase тільки в базовому Tests\TestCase, де знаходиться safety guard.

Глобальне повторне підключення trait у tests/Pest.php і локальні uses(RefreshDatabase::class) в окремих тестах прибрала.

Після цього структура стала однозначною: якщо тест використовує механізм refresh, він отримує його через контрольовану базову точку.

Після зміни я перевірила, що trait більше не дублюється

Перевірка тестової інфраструктури
rg -n \  'RefreshDatabase|uses\(.*RefreshDatabase' \  tests

Для safety-зміни недостатньо просто відредагувати один файл.

Я окремо перевірила весь каталог тестів, щоб переконатися, що другий шлях не залишився десь у Feature або Integration tests.

Після очищення власником RefreshDatabase залишився лише захищений базовий TestCase.

Другий захисний шар - окремий MySQL-користувач тільки для тестів

Тестовий користувач бачить лише test database
CREATE USER 'app_test_user'@'%'IDENTIFIED BY 'use-a-secret-here'; GRANT ALL PRIVILEGESON app_test.*TO 'app_test_user'@'%';

Тут важливе не ім’я користувача, а область його повноважень.

Test user має мати достатньо прав для міграцій, очищення та запису в тестову базу, але не повинен мати доступу до development або recovery databases.

Тепер навіть якщо Laravel помилково спробує підключитися до іншої бази, MySQL має відповісти Access denied.

.env.testing тепер містить не тільки іншу базу, а й іншу identity

Приклад ізольованої testing-конфігурації
DB_CONNECTION=mysqlDB_HOST=mysqlDB_PORT=3306DB_DATABASE=app_testDB_USERNAME=app_test_userDB_PASSWORD=testing-secret

Раніше від development відрізнялася переважно назва бази.

Після зміни відрізняється ще й database identity.

Це означає, що помилка в одному параметрі більше не дає тестам автоматичного доступу до всього, що бачить development user.

Я перевірила не тільки успішний доступ, а й гарантовану відмову

Негативна перевірка є частиною захисту
test user -> app_testPASS  test user -> app_localAccess denied  test user -> app_recoveryAccess denied

У security-перевірці недостатньо довести, що правильний шлях працює.

Потрібно також довести, що неправильний шлях фізично заборонений.

Тестовий користувач успішно підключався до app_test, а спроби доступу до основної та recovery databases завершувалися відмовою MySQL.

Після ізоляції я знову запустила backend-тести

Цільовий набір пройшов після зміни прав
96 tests1582 assertionsPASS

Database-level isolation має сенс лише тоді, коли нормальний testing workflow після неї продовжує працювати.

Після переведення .env.testing на окремого користувача цільовий backend-набір із 96 тестів і 1582 assertions пройшов успішно.

Тобто тестовий користувач отримав достатньо прав для своєї роботи, але не більше.

Тепер захист працює навіть при відмові одного шару

Framework guard плюс database-level privilege isolation
Test starts    |    vLaravel guard    |    | wrong environment?    | wrong database?    X stop    |    vMySQL connection    |    | user has rights    | only to app_test    vRefreshDatabase

Найцінніший урок був не про RefreshDatabase

Конкретний bug можна виправити один раз, але клас помилок залишиться.

Можна забути trait у новому тесті, змінити bootstrap, додати інший runner або випадково підмінити environment variable.

Тому надійніша система не покладається на одну правильну конфігурацію.

Вона будує незалежні межі так, щоб помилка в application layer не автоматично ставала destructive database operation.

Мій список перевірки для destructive database tests

Після цього інциденту я перевіряю:

  • Чи test environment має окрему базу?
  • Чи destructive refresh проходить через один контрольований метод?
  • Чи RefreshDatabase не підключений повторно в Pest або окремих тестах?
  • Чи guard перевіряє і environment, і фактичну назву активної бази?
  • Чи testing використовує окремого MySQL-користувача?
  • Чи цей користувач має права тільки на test database?
  • Чи негативна перевірка доступу до development database повертає Access denied?
  • Чи recovery database теж недоступна test user?
  • Чи після зміни прав повний потрібний набір тестів проходить?
  • Чи production data після recovery нейтралізована для локального середовища?

.env.testing потрібен, але сам по собі він не є захистом

Після цього інциденту я більше не сприймаю окремий .env.testing як повну гарантію безпеки бази.

Він потрібен. Guard у Tests\TestCase теж потрібен. Але обидва знаходяться на рівні framework configuration.

Нижче має бути ще один незалежний бар’єр: MySQL-користувач, який фізично не має права торкатися інших баз.

Тоді навіть якщо framework bootstrap або test configuration одного дня поводитимуться не так, як я очікую, destructive test не отримає authority над development database.

Надійна модель для мене тепер така: framework guard + окремий test database user + database-level privilege isolation.

Правильна назва test database зменшує ризик. Окремий MySQL-користувач перетворює це очікування на реальну межу повноважень.