605 lines
28 KiB
PHP
605 lines
28 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('keeps implicit TLS when MAIL_SCHEME is smtps on a non-standard port — Codex R15#4, P1a', function () {
|
|
// The finding, first half: an operator who already has MAIL_SCHEME=smtps
|
|
// (Laravel's own dedicated, unambiguous encryption knob) must not have
|
|
// that downgraded to STARTTLS just because their submission port is not
|
|
// 465 — a relay speaking implicit TLS on, say, 587 stops accepting mail
|
|
// the moment this migration replaces a working config with a guess.
|
|
clearMailboxSeed();
|
|
config()->set('mail.mailers.smtp.scheme', 'smtps');
|
|
config()->set('mail.mailers.smtp.port', 587);
|
|
|
|
loadMailboxSeedMigration()->up();
|
|
|
|
expect(Settings::get('mail.encryption'))->toBe('ssl');
|
|
});
|
|
|
|
it('keeps implicit TLS when MAIL_URL uses the smtps scheme on a non-standard port', function () {
|
|
// The finding, second half: the same relay, described through MAIL_URL
|
|
// instead of MAIL_SCHEME. 'smtps' in the URL is resolved through the
|
|
// same Illuminate\Support\ConfigurationUrlParser Laravel itself uses —
|
|
// it surfaces on the merged config as the 'driver' key (parseConfiguration
|
|
// maps a URL scheme into 'driver', not 'scheme' — verified directly
|
|
// against the installed class), not re-derived by hand from the URL
|
|
// string a second time.
|
|
clearMailboxSeed();
|
|
config()->set('mail.mailers.smtp.scheme', null);
|
|
config()->set('mail.mailers.smtp.url', 'smtps://user:pass@smtp.clupilot.test:587');
|
|
|
|
loadMailboxSeedMigration()->up();
|
|
|
|
expect(Settings::get('mail.encryption'))->toBe('ssl');
|
|
});
|
|
|
|
it('treats the invalid MAIL_SCHEME=tls value this machine\'s own .env carries as no scheme at all', function () {
|
|
// This container's real .env has exactly this value. Symfony's
|
|
// EsmtpTransportFactory::getSupportedSchemes() only knows 'smtp' and
|
|
// 'smtps' — 'tls' is not a real scheme under either spelling, so it must
|
|
// fall through to the port heuristic (587 -> the ENCRYPTION CHOICE
|
|
// named 'tls', a coincidence of spelling, not the scheme) exactly like
|
|
// no MAIL_SCHEME at all — reproducing this installation's actual
|
|
// behaviour unchanged. The point: an unrecognised scheme string must not
|
|
// silently steer the result toward 'ssl' or 'none' either.
|
|
clearMailboxSeed();
|
|
config()->set('mail.mailers.smtp.scheme', 'tls');
|
|
config()->set('mail.mailers.smtp.port', 587);
|
|
|
|
loadMailboxSeedMigration()->up();
|
|
|
|
expect(Settings::get('mail.encryption'))->toBe('tls');
|
|
});
|
|
|
|
it('derives none from an explicit smtp MAIL_SCHEME, even on port 465', function () {
|
|
// The other explicit direction: MAIL_SCHEME=smtp is just as deliberate a
|
|
// statement as smtps, and must win over the port heuristic the same way
|
|
// — an operator who said "plain smtp" was never asking for implicit TLS
|
|
// just because their port happens to be 465.
|
|
clearMailboxSeed();
|
|
config()->set('mail.mailers.smtp.scheme', 'smtp');
|
|
config()->set('mail.mailers.smtp.port', 465);
|
|
|
|
loadMailboxSeedMigration()->up();
|
|
|
|
expect(Settings::get('mail.encryption'))->toBe('none');
|
|
});
|
|
|
|
it('does not treat a plain smtp:// MAIL_URL scheme as a stated encryption choice', function () {
|
|
// Asymmetric with 'smtps://' on purpose: the URL's scheme slot is doing
|
|
// double duty in Laravel's own MailManager (it is also how "which
|
|
// transport" gets chosen) and 'smtp://' is the ordinary, only
|
|
// real-world spelling for this mailer's URL — not a deliberate "no
|
|
// encryption" statement. Only 'smtps://' is unambiguous enough to
|
|
// promote; 'smtp://' is left to the port heuristic below, same as no
|
|
// scheme at all — proven here by still reaching 'ssl' on port 465
|
|
// despite the explicit smtp:// scheme in the URL.
|
|
clearMailboxSeed();
|
|
config()->set('mail.mailers.smtp.scheme', null);
|
|
config()->set('mail.mailers.smtp.url', 'smtp://user:pass@smtp.clupilot.test:465');
|
|
|
|
loadMailboxSeedMigration()->up();
|
|
|
|
expect(Settings::get('mail.encryption'))->toBe('ssl');
|
|
});
|
|
|
|
it('prefers an explicit MAIL_SCHEME over a conflicting MAIL_URL scheme', function () {
|
|
// MAIL_SCHEME is Laravel's single-purpose encryption knob; the URL's
|
|
// scheme is doing double duty (see above). When both are present and
|
|
// disagree, the unambiguous one wins — matching createSmtpTransport()'s
|
|
// own precedence ($config['scheme'] ?? <port fallback>, checked before
|
|
// anything born from the URL even enters the picture).
|
|
clearMailboxSeed();
|
|
config()->set('mail.mailers.smtp.scheme', 'smtp');
|
|
config()->set('mail.mailers.smtp.url', 'smtps://user:pass@smtp.clupilot.test:587');
|
|
|
|
loadMailboxSeedMigration()->up();
|
|
|
|
expect(Settings::get('mail.encryption'))->toBe('none');
|
|
});
|
|
|
|
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('resolves host, port, username and password from MAIL_URL when the individual SMTP keys are unset', function () {
|
|
// Codex R15#3, P1: an install may configure SMTP through Laravel's OTHER
|
|
// supported form, MAIL_URL (config/mail.php carries 'url' =>
|
|
// env('MAIL_URL')), instead of MAIL_HOST/MAIL_USERNAME/MAIL_PASSWORD.
|
|
// When it does, the individual keys sit at config/mail.php's own
|
|
// placeholder defaults below (127.0.0.1, 2525, null, null) because
|
|
// nothing ever set them — MailManager only substitutes the URL's
|
|
// components in at transport-build time, inside its own protected
|
|
// getConfig(). Read raw, as this migration used to, that is exactly
|
|
// UNCONFIGURED_HOST / UNCONFIGURED_PORT: it stored a blank host, port 0,
|
|
// and an unconfigured mailbox for an install that was sending mail
|
|
// successfully.
|
|
clearMailboxSeed();
|
|
config()->set('mail.mailers.smtp.host', '127.0.0.1');
|
|
config()->set('mail.mailers.smtp.port', 2525);
|
|
config()->set('mail.mailers.smtp.username', null);
|
|
config()->set('mail.mailers.smtp.password', null);
|
|
config()->set('mail.mailers.smtp.url', 'smtp://no-reply%40clupilot.com:s3cr%40t@smtp.clupilot.test:587');
|
|
|
|
loadMailboxSeedMigration()->up();
|
|
|
|
expect(Settings::get('mail.host'))->toBe('smtp.clupilot.test')
|
|
->and(Settings::get('mail.port'))->toBe(587)
|
|
->and(Settings::get('mail.encryption'))->toBe('tls');
|
|
|
|
$box = Mailbox::findByKey('no-reply');
|
|
|
|
// isConfigured() is exactly what MailboxResolver checks before handing a
|
|
// purpose to MailboxTransport — false here would mean every purpose
|
|
// mailer falls through to "unconfigured" and throws on send, the outage
|
|
// this finding describes.
|
|
expect($box->isConfigured())->toBeTrue()
|
|
->and($box->smtpUsername())->toBe('no-reply@clupilot.com')
|
|
// Decoded from the URL's percent-encoding the same way MailManager
|
|
// decodes it (rawurldecode inside ConfigurationUrlParser), not left
|
|
// as the literal 's3cr%40t'.
|
|
->and($box->password)->toBe('s3cr@t');
|
|
});
|
|
|
|
it("prefers MAIL_URL over the individual SMTP keys when both are set, matching MailManager's own precedence", function () {
|
|
// Verified against the real (protected) Illuminate\Mail\MailManager::
|
|
// getConfig() via reflection, not guessed: with both an 'url' and
|
|
// individually-set host/port/username/password present, the URL's
|
|
// components win for every field it supplies — Illuminate\Support\
|
|
// ConfigurationUrlParser::getPrimaryOptions() array_merges them in AFTER
|
|
// the individual keys, and array_filter there only drops what the URL
|
|
// left null. Decoy values stand in for the individual keys here
|
|
// specifically so a migration that read them instead of the URL would
|
|
// fail this test with THEIR value, not a blank one — the sibling test
|
|
// above already covers the blank/placeholder-default shape of the bug.
|
|
clearMailboxSeed();
|
|
config()->set('mail.mailers.smtp.host', 'decoy-host.invalid');
|
|
config()->set('mail.mailers.smtp.port', 9999);
|
|
config()->set('mail.mailers.smtp.username', 'decoy@invalid.test');
|
|
config()->set('mail.mailers.smtp.password', 'decoy-password');
|
|
config()->set('mail.mailers.smtp.url', 'smtp://no-reply%40clupilot.com:s3cr%40t@smtp.clupilot.test:587');
|
|
|
|
loadMailboxSeedMigration()->up();
|
|
|
|
expect(Settings::get('mail.host'))->toBe('smtp.clupilot.test')
|
|
->and(Settings::get('mail.port'))->toBe(587);
|
|
|
|
$box = Mailbox::findByKey('no-reply');
|
|
|
|
expect($box->smtpUsername())->toBe('no-reply@clupilot.com')
|
|
->and($box->password)->toBe('s3cr@t');
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
// --- Codex R15#4, P1b: an existing SMTP relay with a host/from address but
|
|
// no username or password (a trusted local or private-network relay) must
|
|
// still be taken over for real — not seeded as a placeholder — with
|
|
// authenticates recording that it needs no password, distinct from a
|
|
// placeholder that simply has not had one typed in yet.
|
|
|
|
it('takes over a relay with a real host but no username or password, marking it as not authenticating', function () {
|
|
clearMailboxSeed();
|
|
config()->set('mail.mailers.smtp.host', 'relay.internal.example');
|
|
config()->set('mail.mailers.smtp.username', '');
|
|
config()->set('mail.mailers.smtp.password', '');
|
|
config()->set('mail.from.address', 'no-reply@clupilot.local');
|
|
|
|
loadMailboxSeedMigration()->up();
|
|
|
|
$box = Mailbox::findByKey('no-reply');
|
|
|
|
// The real From address, not the 'no-reply@clupilot.com' placeholder —
|
|
// before this fix, $isConfigured required a username and this relay has
|
|
// none, so it would have been seeded as a placeholder and the new
|
|
// transport would refuse to send for it, exactly the outage the finding
|
|
// describes.
|
|
expect($box->address)->toBe('no-reply@clupilot.local')
|
|
->and($box->authenticates)->toBeFalse()
|
|
->and($box->isConfigured())->toBeTrue()
|
|
// Null, not '': an empty username column must read as "no separate
|
|
// login" through Mailbox::smtpUsername()'s own fallback, not as a
|
|
// literal empty-string login distinct from having none at all.
|
|
->and($box->username)->toBeNull();
|
|
});
|
|
|
|
it('marks authenticates true, and the mailbox as needing a password, when only a username was resolved', function () {
|
|
// The other half of the distinction the brief draws: a username with no
|
|
// password is an operator who started typing, not one who chose no
|
|
// auth. authenticates must stay true so isConfigured() keeps asking for
|
|
// a password rather than reading this as a deliberately open relay.
|
|
clearMailboxSeed();
|
|
config()->set('mail.mailers.smtp.host', 'relay.internal.example');
|
|
config()->set('mail.mailers.smtp.username', 'no-reply@clupilot.com');
|
|
config()->set('mail.mailers.smtp.password', '');
|
|
|
|
loadMailboxSeedMigration()->up();
|
|
|
|
$box = Mailbox::findByKey('no-reply');
|
|
|
|
expect($box->authenticates)->toBeTrue()
|
|
->and($box->getRawOriginal('password'))->toBeNull()
|
|
->and($box->isConfigured())->toBeFalse();
|
|
});
|
|
|
|
it('leaves the four placeholder mailboxes authenticating by default, even when the real account does not', function () {
|
|
// authenticates=false must land ONLY on the account the takeover
|
|
// actually resolved — support@/billing@/office@/info@ are fabricated
|
|
// placeholder addresses with no real credentials behind them at all, and
|
|
// must keep asking for a password (authenticates=true, the column's own
|
|
// default) so they read as "not yet set up", not as "deliberately open".
|
|
// Getting this backwards would let a made-up placeholder address pass
|
|
// isConfigured() and be handed to MailboxTransport for real.
|
|
clearMailboxSeed();
|
|
config()->set('mail.mailers.smtp.host', 'relay.internal.example');
|
|
config()->set('mail.mailers.smtp.username', '');
|
|
config()->set('mail.mailers.smtp.password', '');
|
|
config()->set('mail.from.address', 'no-reply@clupilot.local');
|
|
|
|
loadMailboxSeedMigration()->up();
|
|
|
|
foreach (['support', 'billing', 'office', 'info'] as $key) {
|
|
$box = Mailbox::findByKey($key);
|
|
|
|
expect($box->authenticates)->toBeTrue("{$key} should still default to authenticates=true")
|
|
->and($box->isConfigured())->toBeFalse("{$key} should still read as unconfigured");
|
|
}
|
|
});
|
|
|
|
it('never seeds a blank address for the real account when neither a username nor a from address resolved', function () {
|
|
// A real host alone is not enough to know WHO mail should claim to be
|
|
// from — without this guard, a relay with a host but no username, no
|
|
// password, AND no MAIL_FROM_ADDRESS would resolve an empty address
|
|
// string. Falling back to the placeholder shape here (as if the account
|
|
// were not taken over at all) is safer than writing a blank address.
|
|
clearMailboxSeed();
|
|
config()->set('mail.mailers.smtp.host', 'relay.internal.example');
|
|
config()->set('mail.mailers.smtp.username', '');
|
|
config()->set('mail.mailers.smtp.password', '');
|
|
config()->set('mail.from.address', 'hello@example.com'); // config/mail.php's own unconfigured placeholder
|
|
|
|
loadMailboxSeedMigration()->up();
|
|
|
|
$box = Mailbox::findByKey('no-reply');
|
|
|
|
expect($box->address)->toBe('no-reply@clupilot.com')
|
|
->and($box->address)->not->toBe('');
|
|
});
|
|
|
|
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 nothing at all is configured, because it never lands anywhere', function () {
|
|
// Reproduces the Critical finding: $isConfigured is false for every key
|
|
// when nothing was resolved, so the carry-over never runs — the old code
|
|
// deleted the vault row unconditionally regardless.
|
|
//
|
|
// Host pinned to the placeholder explicitly, not left at this
|
|
// container's real .env value: Codex R15#4, P1b made $isConfigured
|
|
// sensitive to the HOST too (a real host with no username is now a
|
|
// legitimate unauthenticated relay, seeded for real — see the block of
|
|
// tests above this one), so this scenario has to say "nothing
|
|
// configured" on every signal itself rather than only blanking the
|
|
// username and relying on the rest to already be blank by accident.
|
|
clearMailboxSeed();
|
|
config()->set('mail.mailers.smtp.host', '127.0.0.1');
|
|
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();
|
|
});
|