up() with config the REAL migrate run (already applied * once by RefreshDatabase before any test's own beforeEach) could not have * used, to reach branches nothing else exercises. */ function loadMailboxSeedMigration(): object { return require database_path('migrations/2026_07_28_100000_seed_mailboxes_from_environment.php'); } beforeEach(function () { config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); }); it('does not copy MAIL_SCHEME through — a Symfony transport only knows smtp and smtps', function () { clearMailboxSeed(); config()->set('mail.mailers.smtp.scheme', 'not-a-symfony-scheme'); config()->set('mail.mailers.smtp.port', 587); loadMailboxSeedMigration()->up(); expect(Settings::get('mail.encryption'))->toBe('tls'); }); it('derives ssl from port 465, regardless of what scheme is configured', function () { clearMailboxSeed(); config()->set('mail.mailers.smtp.scheme', 'irrelevant'); config()->set('mail.mailers.smtp.port', 465); loadMailboxSeedMigration()->up(); expect(Settings::get('mail.encryption'))->toBe('ssl') ->and(Settings::get('mail.port'))->toBe(465); }); it('treats config/mail.php\'s own 127.0.0.1 / 2525 defaults as unset, not as a real relay', function () { clearMailboxSeed(); config()->set('mail.mailers.smtp.host', '127.0.0.1'); config()->set('mail.mailers.smtp.port', 2525); loadMailboxSeedMigration()->up(); // '' / 0 is exactly what MailboxTransport's guards treat as "not // configured" — 127.0.0.1 is not '', so it would have sailed straight // past the Task 4 guard and quietly dialled localhost instead. expect(Settings::get('mail.host'))->toBe('') ->and(Settings::get('mail.port'))->toBe(0); }); it('still stores a deliberately configured host and port untouched', function () { clearMailboxSeed(); config()->set('mail.mailers.smtp.host', 'smtp.example.test'); config()->set('mail.mailers.smtp.port', 587); loadMailboxSeedMigration()->up(); expect(Settings::get('mail.host'))->toBe('smtp.example.test') ->and(Settings::get('mail.port'))->toBe(587); }); it('carries the vault password across as ciphertext, never decrypting and re-encrypting it', function () { clearMailboxSeed(); config()->set('mail.mailers.smtp.username', 'no-reply@clupilot.com'); $ciphertext = app(SecretCipher::class)->encrypt('rotated-through-the-console'); DB::table('app_secrets')->insert([ 'key' => 'mail.password', 'value' => $ciphertext, 'created_at' => now(), 'updated_at' => now(), ]); loadMailboxSeedMigration()->up(); $stored = DB::table('mailboxes')->where('key', 'no-reply')->value('password'); // Byte-for-byte, not just "decrypts to the same plaintext": AES-CBC's // random IV means re-encrypting the SAME plaintext produces a DIFFERENT // ciphertext string, so this is what actually discriminates "carried // as-is" from "decrypted and re-encrypted" — the migration's own claim, // never exercised by any test before this one. expect($stored)->toBe($ciphertext) ->and(app(SecretCipher::class)->decrypt($stored))->toBe('rotated-through-the-console'); }); it('leaves the vault row in the database even after a normal, fully successful carry-over', function () { clearMailboxSeed(); config()->set('mail.mailers.smtp.username', 'no-reply@clupilot.com'); $ciphertext = app(SecretCipher::class)->encrypt('rotated-through-the-console'); DB::table('app_secrets')->insert([ 'key' => 'mail.password', 'value' => $ciphertext, 'created_at' => now(), 'updated_at' => now(), ]); loadMailboxSeedMigration()->up(); expect(DB::table('app_secrets')->where('key', 'mail.password')->value('value'))->toBe($ciphertext); }); it('does not destroy a stored vault password when MAIL_USERNAME is empty, because it never lands anywhere', function () { // Reproduces the Critical finding: $isConfigured is false for every key // when MAIL_USERNAME is empty, so the carry-over never runs — the old // code deleted the vault row unconditionally regardless. clearMailboxSeed(); config()->set('mail.mailers.smtp.username', ''); $ciphertext = app(SecretCipher::class)->encrypt('nowhere-to-go'); DB::table('app_secrets')->insert([ 'key' => 'mail.password', 'value' => $ciphertext, 'created_at' => now(), 'updated_at' => now(), ]); loadMailboxSeedMigration()->up(); expect(DB::table('app_secrets')->where('key', 'mail.password')->value('value'))->toBe($ciphertext) ->and(Mailbox::findByKey('no-reply')->getRawOriginal('password'))->toBeNull(); }); it('survives a migrate, rollback, migrate-again cycle without losing the credential', function () { // Reproduces the other Critical finding: down() deletes the mailbox // rows, which is where the ONLY copy of the password would end up if the // vault row had been deleted (the old behaviour) during the first up(). clearMailboxSeed(); config()->set('mail.mailers.smtp.username', 'no-reply@clupilot.com'); $ciphertext = app(SecretCipher::class)->encrypt('rotated-through-the-console'); DB::table('app_secrets')->insert([ 'key' => 'mail.password', 'value' => $ciphertext, 'created_at' => now(), 'updated_at' => now(), ]); $migration = loadMailboxSeedMigration(); $migration->up(); $migration->down(); $migration->up(); expect(DB::table('mailboxes')->where('key', 'no-reply')->value('password'))->toBe($ciphertext); }); it('is safe to run twice — a migrations-table drift must not crash on the unique key', function () { // The real baseline from RefreshDatabase's own migrate is already there // (nothing cleared it) — calling up() again must update those rows in // place, not attempt a second INSERT under the same key. // // A plain try/catch, NOT expect(fn () => ...)->not->toThrow(Throwable::class): // verified directly that the latter is not reliable here — a closure that // unconditionally throws still passes ->not->toThrow(Throwable::class) in // this Pest version, which would have made this test pass whether or not // the migration actually crashed. $threw = null; try { loadMailboxSeedMigration()->up(); } catch (Throwable $e) { $threw = $e; } expect($threw)->toBeNull(); expect(Mailbox::query()->pluck('key')->sort()->values()->all()) ->toBe(['billing', 'info', 'no-reply', 'office', 'support']); }); it('un-seeds on rollback, and does not leave the settings cache holding what it just deleted', function () { // Against the real baseline again — this is what a real rollback tears // down. Warms the cache first the same way a real request would, so a // migration that bypassed Settings::forget() (a raw DB delete) and left // the Cache::forever() entry behind would still read back the old value. expect(Settings::get('mail.purpose.system'))->toBe('no-reply'); loadMailboxSeedMigration()->down(); expect(Mailbox::query()->count())->toBe(0) ->and(Settings::get('mail.purpose.system'))->toBeNull() ->and(Settings::get('mail.host'))->toBeNull(); }); it('prints an operator-visible warning when SECRETS_KEY is missing and a .env password had to be skipped', function () { // No vault row exists here — nothing at all ends up in mailboxes.password // for this key, so "was NOT copied" is the true statement. The sibling // test below is the other half: a vault row DOES exist, the password IS // carried across regardless of $canEncrypt, and the warning must say a // different, equally true thing rather than reuse this wording. clearMailboxSeed(); config()->set('admin_access.secrets_key', ''); // canEncrypt false config()->set('mail.mailers.smtp.username', 'no-reply@clupilot.com'); config()->set('mail.mailers.smtp.password', 'dummy-nicht-echt'); ob_start(); loadMailboxSeedMigration()->up(); $output = ob_get_clean(); expect($output)->toContain('SECRETS_KEY') ->toContain('no-reply@clupilot.com') ->toContain('was NOT copied') ->and(DB::table('mailboxes')->where('key', 'no-reply')->value('password'))->toBeNull(); }); it('says a stored password was carried over, not that nothing was copied, when a vault row already exists and SECRETS_KEY is missing', function () { // Deferred #4: the vault-carry block a few lines below this branch runs // UNCONDITIONALLY on $storedPassword !== null — it needs no new // encryption, since the value is already ciphertext — so this mailbox // ends up WITH a working password even though $canEncrypt is false. The // old message said "was NOT copied into the mailbox table" regardless, // which is true of the .env value and false of the outcome: an operator // reading it mid mail-outage would be sent to re-enter a password that // is already sitting in the column. clearMailboxSeed(); config()->set('mail.mailers.smtp.username', 'no-reply@clupilot.com'); config()->set('mail.mailers.smtp.password', 'dummy-nicht-echt'); // Encrypted under the valid key beforeEach() set up — standing in for a // password stored earlier, through the console, while SECRETS_KEY still // existed. THEN it goes missing, which is the state this migration // actually runs under below. $ciphertext = app(SecretCipher::class)->encrypt('rotated-through-the-console'); DB::table('app_secrets')->insert([ 'key' => 'mail.password', 'value' => $ciphertext, 'created_at' => now(), 'updated_at' => now(), ]); config()->set('admin_access.secrets_key', ''); // canEncrypt false, now ob_start(); loadMailboxSeedMigration()->up(); $output = ob_get_clean(); expect($output)->toContain('SECRETS_KEY') ->toContain('no-reply@clupilot.com') ->toContain('carried over') ->not->toContain('was NOT copied') ->and(DB::table('mailboxes')->where('key', 'no-reply')->value('password'))->toBe($ciphertext); }); it('stays silent when there is no .env password to skip in the first place', function () { clearMailboxSeed(); config()->set('admin_access.secrets_key', ''); // canEncrypt false config()->set('mail.mailers.smtp.username', 'no-reply@clupilot.com'); config()->set('mail.mailers.smtp.password', ''); ob_start(); loadMailboxSeedMigration()->up(); $output = ob_get_clean(); expect($output)->toBe(''); }); it('Settings::forget removes the row and does not leave a stale cache entry behind', function () { Settings::set('probe.key', 'value'); expect(Settings::get('probe.key'))->toBe('value'); Settings::forget('probe.key'); expect(Settings::get('probe.key'))->toBeNull() ->and(DB::table('app_settings')->where('key', 'probe.key')->exists())->toBeFalse(); });