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'); } /** * This container's real .env carries actual MAIL_HOST/MAIL_PORT/MAIL_URL * values (see the migration's UNCONFIGURED_HOST docblock) — env() reads * those directly, regardless of what an individual test's config()->set() * says, and Env::getRepository() is a process-wide singleton, not something * RefreshDatabase or a fresh Application instance resets between tests. Left * alone, every test in this file would be quietly deciding "explicitly * supplied" (Codex R15#8, P1) based on this machine's own mail setup rather * than on what it actually configured — true today only because this * container happens to have real mail credentials, and wrong the moment it * does not. Neutralised here for every test by default; withRealEnv() opts * individual tests back in to prove the explicitly-supplied paths themselves. */ $realMailEnvBefore = []; beforeEach(function () use (&$realMailEnvBefore) { config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); $realMailEnvBefore = captureRealEnv(['MAIL_HOST', 'MAIL_PORT', 'MAIL_URL']); setRealEnv(['MAIL_HOST' => null, 'MAIL_PORT' => null, 'MAIL_URL' => null]); }); afterEach(function () use (&$realMailEnvBefore) { setRealEnv($realMailEnvBefore); }); 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 tls (STARTTLS required) from an explicit smtp MAIL_SCHEME, even on port 465 — Codex R15#7, P1', 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. // // But 'smtp' is NOT a "no encryption" statement — it is Symfony's // OPPORTUNISTIC scheme (autoTls, on by default: upgrade to STARTTLS if // the server offers it, never fail if it does not). Round 4 of this // review mapped this case to 'none' on the wrong theory that 'smtp' was // a plain, non-TLS scheme; that silently turned every existing // MAIL_SCHEME=smtp installation's encrypted-when-possible connection // into an always-plaintext one the moment this migration ran — the // credential- and message-exposing bug this test now guards against. // 'tls' (STARTTLS REQUIRED) is deliberately stricter than smtp's own // opportunistic default: a relay that does not offer STARTTLS now fails // loudly instead of sending a mailbox password in the clear. clearMailboxSeed(); config()->set('mail.mailers.smtp.scheme', 'smtp'); config()->set('mail.mailers.smtp.port', 465); loadMailboxSeedMigration()->up(); expect(Settings::get('mail.encryption'))->toBe('tls'); }); it('falls back to the port heuristic when no scheme is stated anywhere — Codex R15#7, P1', function () { // The fifth scenario the R15#7 fix has to keep working, alongside smtp, // smtps, smtp:// and smtps://: nobody stated a scheme at all (no // MAIL_SCHEME, no MAIL_URL). resolveScheme() returns null, the match's // default arm applies, and — since this is not port 465 — that arm must // still read 'tls', exactly as before this fix. A genuinely unstated // scheme was never part of the 'smtp' => 'none' bug and must not become // collateral damage of fixing it. clearMailboxSeed(); config()->set('mail.mailers.smtp.scheme', null); config()->set('mail.mailers.smtp.url', null); config()->set('mail.mailers.smtp.port', 587); loadMailboxSeedMigration()->up(); expect(Settings::get('mail.encryption'))->toBe('tls'); }); 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'] ?? , checked before // anything born from the URL even enters the picture). 'tls', not // 'ssl': MAIL_SCHEME=smtp still wins the conflict, but it means // "STARTTLS required", not implicit TLS — see the R15#7 test above and // the match in seed() for why 'smtp' is no longer 'none' either. 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('tls'); }); it('treats config/mail.php\'s own 127.0.0.1 / 2525 defaults as unset when neither env() nor MAIL_URL says otherwise', function () { // Genuinely unset, on BOTH signals isExplicitlySupplied() now checks — // not just the config()->set() value: this file's own beforeEach() // neutralises the real MAIL_HOST/MAIL_PORT/MAIL_URL this container // actually carries, so env() reads null here exactly like a fresh // install's would, and no MAIL_URL is set either. Codex R15#8, P1 is the // three tests below this one; this is the control that proves the // sentinel fallback itself still fires when it is supposed to. 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('stores an explicitly configured MAIL_PORT=2525 instead of zeroing it — Codex R15#8, P1', function () { // The finding itself: a relay genuinely listening on 2525 (config/mail. // php's own placeholder port) is indistinguishable from "nothing // configured" by the sentinel comparison alone. env('MAIL_PORT') // returning ANYTHING — including '2525' — is what now proves an operator // actually wrote this, not config/mail.php's substitution. clearMailboxSeed(); config()->set('mail.mailers.smtp.host', 'relay.internal.example'); config()->set('mail.mailers.smtp.port', 2525); withRealEnv(['MAIL_PORT' => '2525'], function () { loadMailboxSeedMigration()->up(); }); // Not 0 — MailboxTransport must still be able to dial :2525 for real. expect(Settings::get('mail.port'))->toBe(2525); }); it('stores an explicitly configured MAIL_HOST=127.0.0.1 instead of blanking it — Codex R15#8, P1', function () { // The same finding, the host half: an install genuinely relaying through // localhost — a sidecar SMTP relay in the same container/pod, commonly // paired with MAIL_URL — is indistinguishable from "nothing configured" // by the sentinel comparison alone. clearMailboxSeed(); config()->set('mail.mailers.smtp.host', '127.0.0.1'); config()->set('mail.mailers.smtp.port', 2526); // a real, non-placeholder port alongside it withRealEnv(['MAIL_HOST' => '127.0.0.1'], function () { loadMailboxSeedMigration()->up(); }); // Not '' — MailboxTransport must still be able to dial 127.0.0.1 for real. expect(Settings::get('mail.host'))->toBe('127.0.0.1'); }); it('trusts a host and port supplied through MAIL_URL over the sentinel, even when they equal it exactly', function () { // The finding's other named case: "a port or host that came from the URL // must not be second-guessed by the sentinel at all." Distinct from the // MAIL_URL tests further down — those use a URL host/port that already // differs from the placeholder, so they would pass even without this // fix. This one deliberately points MAIL_URL AT 127.0.0.1:2525 — the // exact sentinel values — so only isExplicitlySupplied()'s suppliedByUrl // half, not the env() half (MAIL_HOST/MAIL_PORT individually stay // unset), can be what saves it. clearMailboxSeed(); config()->set('mail.mailers.smtp.host', '127.0.0.1'); config()->set('mail.mailers.smtp.port', 2525); config()->set('mail.mailers.smtp.url', 'smtp://user:pass@127.0.0.1:2525'); loadMailboxSeedMigration()->up(); expect(Settings::get('mail.host'))->toBe('127.0.0.1') ->and(Settings::get('mail.port'))->toBe(2525); }); 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(); });