diff --git a/database/migrations/2026_07_28_100000_seed_mailboxes_from_environment.php b/database/migrations/2026_07_28_100000_seed_mailboxes_from_environment.php index 4d6d3f0..efb1d13 100644 --- a/database/migrations/2026_07_28_100000_seed_mailboxes_from_environment.php +++ b/database/migrations/2026_07_28_100000_seed_mailboxes_from_environment.php @@ -121,6 +121,20 @@ return new class extends Migration if ($isConfigured && $envPassword !== '') { if ($canEncrypt) { $box->password = $envPassword; + } elseif ($storedPassword !== null) { + // Skipping the .env password beats crashing the migration + // (that is what $canEncrypt guards above) — but this box + // is NOT left without a password: the vault row carried + // below (see the unconditional block after $box->save()) + // runs regardless of $canEncrypt, because it is already + // ciphertext and needs no new encryption. Saying "was NOT + // copied" here would be true only of the .env value and + // false of the outcome — an operator reading this while + // debugging a live mail outage would be sent to re-enter + // a password that is already sitting in the column. + echo ' ! SECRETS_KEY is not set - the SMTP password configured in .env for ' + .$configuredUser.' was not copied from .env, but a password already stored ' + ."for this mailbox was carried over and should still work.\n"; } else { // Skipping beats crashing the migration (that is what // $canEncrypt guards above) — but a skip that LOOKS like diff --git a/tests/Feature/Mail/MailboxSeedMigrationTest.php b/tests/Feature/Mail/MailboxSeedMigrationTest.php index 4c3e822..7fd6b83 100644 --- a/tests/Feature/Mail/MailboxSeedMigrationTest.php +++ b/tests/Feature/Mail/MailboxSeedMigrationTest.php @@ -179,6 +179,11 @@ it('un-seeds on rollback, and does not leave the settings cache holding what it }); 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'); @@ -189,7 +194,44 @@ it('prints an operator-visible warning when SECRETS_KEY is missing and a .env pa $output = ob_get_clean(); expect($output)->toContain('SECRETS_KEY') - ->toContain('no-reply@clupilot.com'); + ->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 () {