CluPilotCloud/tests/Feature/Mail/MailboxSeedMigrationTest.php

337 lines
15 KiB
PHP

<?php
use App\Models\Mailbox;
use App\Services\Secrets\SecretCipher;
use App\Support\Settings;
use Illuminate\Support\Facades\DB;
/**
* Loads a fresh instance of the seed migration's anonymous class every call.
* `require`, not `require_once`: PHP re-evaluates the `return new class ...`
* expression each time, so every call gets its own instance — this is what
* lets a test call ->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('seeds address from mail.from.address and keeps the SMTP login as username when they differ', function () {
// The installation this reproduces: MAIL_USERNAME is a relay login,
// MAIL_FROM_ADDRESS is the address actually in force today. Before this
// fix the login was seeded as the From address and username was left
// null — invalid mail, or mail sent from the relay login instead of the
// sender the system was actually configured to use.
clearMailboxSeed();
config()->set('mail.mailers.smtp.username', 'no-reply@clupilot.com');
config()->set('mail.from.address', 'hello@clupilot.local');
loadMailboxSeedMigration()->up();
$box = Mailbox::findByKey('no-reply');
expect($box->address)->toBe('hello@clupilot.local')
->and($box->username)->toBe('no-reply@clupilot.com');
});
it('leaves username null when the SMTP login and the From address are the same', function () {
// Mailbox::smtpUsername() already falls back to address when username is
// null — writing the identical string into both columns is a redundant
// copy that only has to drift once (an operator changes one in the
// console but not the other) to become a real, harder-to-spot bug.
clearMailboxSeed();
config()->set('mail.mailers.smtp.username', 'ops@clupilot.com');
config()->set('mail.from.address', 'ops@clupilot.com');
loadMailboxSeedMigration()->up();
$box = Mailbox::findByKey('no-reply');
expect($box->address)->toBe('ops@clupilot.com')
->and($box->username)->toBeNull();
});
it('falls back to the SMTP login for address when MAIL_FROM_ADDRESS was never set', function () {
// config/mail.php's own env('MAIL_FROM_ADDRESS', 'hello@example.com') has
// already substituted by the time this migration reads config() — an
// install that never set MAIL_FROM_ADDRESS reads back that placeholder,
// not null, so it has to be recognised the same way UNCONFIGURED_HOST is.
clearMailboxSeed();
config()->set('mail.mailers.smtp.username', 'no-reply@clupilot.com');
config()->set('mail.from.address', 'hello@example.com');
loadMailboxSeedMigration()->up();
$box = Mailbox::findByKey('no-reply');
expect($box->address)->toBe('no-reply@clupilot.com')
->and($box->username)->toBeNull();
});
it('seeds display_name from mail.from.name instead of a hardcoded CluPilot', function () {
clearMailboxSeed();
config()->set('mail.mailers.smtp.username', 'no-reply@clupilot.com');
config()->set('mail.from.name', 'Example Org Support');
loadMailboxSeedMigration()->up();
expect(Mailbox::findByKey('no-reply')->display_name)->toBe('Example Org Support')
->and(Mailbox::findByKey('support')->display_name)->toBe('Example Org Support');
});
it('still derives the placeholder domain from the SMTP login, not from mail.from.address', function () {
// The two can point at different domains. Only the login's domain is
// proven to accept mail for this install — switching the placeholder
// domain to mail.from.address's domain could seed support@/billing@/etc.
// on a domain nobody could ever turn into a working mailbox.
clearMailboxSeed();
config()->set('mail.mailers.smtp.username', 'no-reply@clupilot.com');
config()->set('mail.from.address', 'hello@clupilot.local');
loadMailboxSeedMigration()->up();
expect(Mailbox::findByKey('support')->address)->toBe('support@clupilot.com')
->and(Mailbox::findByKey('billing')->address)->toBe('billing@clupilot.com');
});
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();
});