Під час роботи над великим Laravel-проєктом моя локальна MySQL-база раптово залишилася без основних робочих даних.
На той момент я працювала з backend-тестами, де використовувався RefreshDatabase.
Спочатку я не знала, що саме стало причиною. І це важливо: у такій ситуації дуже легко побачити один підозрілий рядок і одразу оголосити його винним.
Я пішла іншим шляхом: спочатку відновлення даних, потім перевірка тестової інфраструктури, а після цього побудова захисту так, щоб одна помилка більше не могла знищити іншу базу.
Спочатку я перевірила не базу, а весь тестовий запуск
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 уже був правильний захист
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 був підключений у кількох місцях
// 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
// 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-користувач тільки для тестів
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
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 assertionsPASSDatabase-level isolation має сенс лише тоді, коли нормальний testing workflow після неї продовжує працювати.
Після переведення .env.testing на окремого користувача цільовий backend-набір із 96 тестів і 1582 assertions пройшов успішно.
Тобто тестовий користувач отримав достатньо прав для своєї роботи, але не більше.
Тепер захист працює навіть при відмові одного шару
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-користувач перетворює це очікування на реальну межу повноважень.



