393 lines
17 KiB
PHP
393 lines
17 KiB
PHP
<?php
|
|
|
|
use App\Models\Mailbox;
|
|
use App\Services\Mail\MailboxTester;
|
|
use App\Support\Settings;
|
|
use Tests\Support\FakeSmtpServer;
|
|
|
|
beforeEach(function () {
|
|
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
|
|
config()->set('mail.default', 'log'); // as on a development machine
|
|
Settings::set('mail.host', '127.0.0.1');
|
|
Settings::set('mail.port', 1);
|
|
// 'none', not 'tls': FakeSmtpServer only ever speaks plain text (see its
|
|
// own docblock) and never advertises STARTTLS. Since the P1 fix makes
|
|
// 'tls' REQUIRE a STARTTLS upgrade, keeping this default at 'tls' would
|
|
// have broken every success/rejection test below that needs the fake
|
|
// server to complete a full plaintext dialogue — none of which are
|
|
// actually about the TLS policy. The tests that ARE about it set their
|
|
// own encryption explicitly, below.
|
|
Settings::set('mail.encryption', 'none');
|
|
});
|
|
|
|
/*
|
|
| The brief points this beforeEach at 'mail.example.invalid' and asserts the
|
|
| error mentions it. Measured by hand in this container (php artisan tinker
|
|
| equivalent, three runs each): a lookup for 'mail.example.invalid' fails in
|
|
| 0.7-7ms via NXDOMAIN, but that number depends on a resolver being reachable
|
|
| at all — nothing here guarantees that in every environment this suite might
|
|
| run in. 127.0.0.1:1 (nothing listens on port 1) refused every one of those
|
|
| three runs in ~0.2ms with zero DNS involved, and it is the convention this
|
|
| codebase already settled on for the identical reason — see
|
|
| MailboxTransportTest's "LOAD-BEARING for the MAIL_MAILER=log guard" comment.
|
|
| Using it here keeps both tests fast AND deterministic instead of merely fast
|
|
| today.
|
|
*/
|
|
|
|
it('does NOT quietly succeed through the log mailer', function () {
|
|
// The whole point: on dev the default mailer is the log, and a tester
|
|
// that honoured it would report success while writing to a file — a
|
|
// test that proves nothing. The log "transport" never fails, so only a
|
|
// real attempt (which a closed port refuses) can make this assertion
|
|
// meaningful.
|
|
$result = app(MailboxTester::class)->run(
|
|
Mailbox::factory()->create(['address' => 'no-reply@clupilot.com']),
|
|
'ziel@example.com',
|
|
);
|
|
|
|
expect($result['ok'])->toBeFalse()
|
|
->and($result['error'])->not->toBeNull();
|
|
});
|
|
|
|
it('passes the SMTP error through verbatim, because that is the whole diagnosis', function () {
|
|
$result = app(MailboxTester::class)->run(
|
|
Mailbox::factory()->create(['address' => 'no-reply@clupilot.com']),
|
|
'ziel@example.com',
|
|
);
|
|
|
|
// Not "Fehler beim Senden" — the operating system's own sentence, naming
|
|
// the actual host:port and the actual reason.
|
|
expect($result['error'])->toContain('127.0.0.1:1')
|
|
->and($result['error'])->toContain('Connection refused');
|
|
});
|
|
|
|
it('refuses a mailbox without a password rather than pretending to try — when it authenticates', function () {
|
|
// The control case for the block below: authenticates defaults to true
|
|
// on the factory, so this is what the guard must still refuse.
|
|
$box = Mailbox::factory()->create(['password' => null]);
|
|
|
|
$result = app(MailboxTester::class)->run($box, 'ziel@example.com');
|
|
|
|
expect($result['ok'])->toBeFalse()
|
|
->and($result['error'])->toContain('Passwort');
|
|
});
|
|
|
|
// --- Codex R15#4, P1b: an unauthenticated relay (a trusted local or
|
|
// private-network server) must not be refused for lacking a password —
|
|
// Laravel's previous SMTP configuration has always supported sending through
|
|
// one with none configured at all.
|
|
|
|
it('does not refuse an unauthenticated mailbox for lacking a password', function () {
|
|
$server = FakeSmtpServer::succeeding();
|
|
|
|
try {
|
|
Settings::set('mail.host', '127.0.0.1');
|
|
Settings::set('mail.port', $server->port);
|
|
|
|
$box = Mailbox::factory()->create([
|
|
'address' => 'no-reply@clupilot.com', 'username' => null, 'password' => null, 'authenticates' => false,
|
|
]);
|
|
|
|
$result = app(MailboxTester::class)->run($box, 'ziel@example.com');
|
|
|
|
expect($result['ok'])->toBeTrue()
|
|
->and($result['error'])->toBeNull();
|
|
} finally {
|
|
$server->stop();
|
|
}
|
|
});
|
|
|
|
it('never attempts AUTH when the mailbox does not authenticate, even against a server that offers it', function () {
|
|
// succeeding() alone cannot prove this — it never advertises AUTH, so
|
|
// handleAuth() is unreachable regardless of what MailboxTester supplies.
|
|
// succeedingUnlessAuthAttempted() offers AUTH and rejects any attempt,
|
|
// so only a client that never sends "AUTH ..." at all can succeed here.
|
|
$server = FakeSmtpServer::succeedingUnlessAuthAttempted();
|
|
|
|
try {
|
|
Settings::set('mail.host', '127.0.0.1');
|
|
Settings::set('mail.port', $server->port);
|
|
|
|
$box = Mailbox::factory()->create([
|
|
'address' => 'no-reply@clupilot.com', 'username' => null, 'password' => null, 'authenticates' => false,
|
|
]);
|
|
|
|
$result = app(MailboxTester::class)->run($box, 'ziel@example.com');
|
|
|
|
expect($result['ok'])->toBeTrue()
|
|
->and($result['error'])->toBeNull();
|
|
} finally {
|
|
$server->stop();
|
|
}
|
|
});
|
|
|
|
it('still attempts AUTH when the mailbox does authenticate, against the same AUTH-offering server', function () {
|
|
// The control case for the test above: proves succeedingUnlessAuthAttempted()
|
|
// actually discriminates rather than always succeeding regardless of
|
|
// what the client does — an authenticating mailbox reaches handleAuth(),
|
|
// which sends AUTH LOGIN, which this server always rejects.
|
|
$server = FakeSmtpServer::succeedingUnlessAuthAttempted();
|
|
|
|
try {
|
|
Settings::set('mail.host', '127.0.0.1');
|
|
Settings::set('mail.port', $server->port);
|
|
|
|
$box = Mailbox::factory()->create([
|
|
'address' => 'no-reply@clupilot.com', 'password' => 'a-real-password', 'authenticates' => true,
|
|
]);
|
|
|
|
$result = app(MailboxTester::class)->run($box, 'ziel@example.com');
|
|
|
|
expect($result['ok'])->toBeFalse();
|
|
} finally {
|
|
$server->stop();
|
|
}
|
|
});
|
|
|
|
it('stamps last_verified_at only on success', function () {
|
|
$box = Mailbox::factory()->create(['address' => 'no-reply@clupilot.com']);
|
|
|
|
app(MailboxTester::class)->run($box, 'ziel@example.com');
|
|
|
|
expect($box->fresh()->last_verified_at)->toBeNull();
|
|
});
|
|
|
|
it('gives a readable message instead of throwing when SECRETS_KEY is unusable', function () {
|
|
// Facts the brief could not know: SECRETS_KEY is not set on this
|
|
// installation at all (the mail settings page's own banner says so).
|
|
// $box->password decrypts on read, so touching it throws a
|
|
// RuntimeException out of SecretCipher — Task 7's EditMailbox::save()
|
|
// shipped a Critical fix for exactly this shape of bug. The tester
|
|
// touches the very same cipher and needs the very same guard.
|
|
$box = Mailbox::factory()->create(['address' => 'no-reply@clupilot.com']);
|
|
|
|
config()->set('admin_access.secrets_key', '');
|
|
|
|
$result = app(MailboxTester::class)->run($box, 'ziel@example.com');
|
|
|
|
expect($result['ok'])->toBeFalse()
|
|
->and($result['error'])->toBe(__('mail_settings.no_key'));
|
|
});
|
|
|
|
// --- Codex R15#9, P2: isUsable() proves SECRETS_KEY is well-formed (right
|
|
// length, right shape) — never that THIS row's ciphertext was actually
|
|
// encrypted under it. A rotated key, or a row seeded/encrypted under a
|
|
// different one, is exactly the case isUsable() cannot see by design (its
|
|
// own docblock says so): it sails straight past the guard above and then
|
|
// throws a DecryptException the instant $box->password is actually read —
|
|
// the same uncaught-out-of-a-Livewire-action shape the guard above exists
|
|
// for, just a failure the earlier round-3/round-5 guards do not cover.
|
|
|
|
it('gives a readable message instead of throwing when the stored password does not decrypt under the current key', function () {
|
|
// Encrypt under one valid key, then swap in a DIFFERENT, equally
|
|
// well-formed 32-byte key before the read — MailboxModelTest's proven
|
|
// recipe ("is keyed to SECRETS_KEY — rotating it makes the password
|
|
// unreadable") for making $box->password throw DecryptException rather
|
|
// than merely returning wrong bytes. A malformed key (covered above)
|
|
// never gets far enough to decrypt anything at all — this is a
|
|
// DIFFERENT failure: the key is fine, the ciphertext just was not made
|
|
// for it.
|
|
$box = Mailbox::factory()->create(['address' => 'no-reply@clupilot.com', 'password' => 'geheim']);
|
|
|
|
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
|
|
|
|
$result = app(MailboxTester::class)->run($box->fresh(), 'ziel@example.com');
|
|
|
|
expect($result['ok'])->toBeFalse()
|
|
->and($result['error'])->toBe(__('mail_settings.test_password_undecryptable'));
|
|
});
|
|
|
|
// --- Codex R15#5, P2: an unauthenticated mailbox never reads $box->password
|
|
// (see the credentials ternary above in run() and the guard just above it),
|
|
// so requiring a usable SECRETS_KEY before even checking authenticates
|
|
// refused a configuration real sending accepts — on exactly the "fresh
|
|
// install, no SECRETS_KEY yet" installation the unauthenticated-relay
|
|
// support (Codex R15#4, P1b) exists for.
|
|
|
|
it('does not require a usable cipher for an unauthenticated mailbox, even with no key configured at all', function () {
|
|
$server = FakeSmtpServer::succeeding();
|
|
|
|
try {
|
|
config()->set('admin_access.secrets_key', '');
|
|
Settings::set('mail.host', '127.0.0.1');
|
|
Settings::set('mail.port', $server->port);
|
|
|
|
$box = Mailbox::factory()->create([
|
|
'address' => 'no-reply@clupilot.com', 'username' => null, 'password' => null, 'authenticates' => false,
|
|
]);
|
|
|
|
$result = app(MailboxTester::class)->run($box, 'ziel@example.com');
|
|
|
|
expect($result['ok'])->toBeTrue()
|
|
->and($result['error'])->toBeNull();
|
|
} finally {
|
|
$server->stop();
|
|
}
|
|
});
|
|
|
|
/*
|
|
| LOAD-BEARING for the success path. Every test above only proves a FAILURE
|
|
| is reported correctly — none of them ever reaches
|
|
| `$box->forceFill(['last_verified_at' => now()])->save()`, so all of them
|
|
| would stay green even if that line were deleted outright. A real success
|
|
| needs something real to answer on the other end of the socket; see
|
|
| FakeSmtpServer for why that has to be a subprocess rather than an in-process
|
|
| fake.
|
|
*/
|
|
it('actually delivers and stamps last_verified_at when the server accepts the message', function () {
|
|
$server = FakeSmtpServer::succeeding();
|
|
|
|
try {
|
|
Settings::set('mail.host', '127.0.0.1');
|
|
Settings::set('mail.port', $server->port);
|
|
|
|
$box = Mailbox::factory()->create(['address' => 'no-reply@clupilot.com']);
|
|
|
|
$result = app(MailboxTester::class)->run($box, 'ziel@example.com');
|
|
|
|
expect($result['ok'])->toBeTrue()
|
|
->and($result['error'])->toBeNull()
|
|
->and($box->fresh()->last_verified_at)->not->toBeNull();
|
|
} finally {
|
|
$server->stop();
|
|
}
|
|
});
|
|
|
|
it('passes a real SMTP rejection through verbatim too, not only a refused connection', function () {
|
|
// The strongest version of "the server's own sentence": an actual
|
|
// protocol-level 5xx from an actual SMTP dialogue, not merely the
|
|
// operating system's "connection refused". Proves MailboxTester's catch
|
|
// block does not paraphrase or replace what Symfony Mailer reports.
|
|
$server = FakeSmtpServer::rejectingAt('rcpt', '550 5.1.1 No such user ziel@example.com');
|
|
|
|
try {
|
|
Settings::set('mail.host', '127.0.0.1');
|
|
Settings::set('mail.port', $server->port);
|
|
|
|
$box = Mailbox::factory()->create(['address' => 'no-reply@clupilot.com']);
|
|
|
|
$result = app(MailboxTester::class)->run($box, 'ziel@example.com');
|
|
|
|
expect($result['ok'])->toBeFalse()
|
|
->and($result['error'])->toContain('550 5.1.1 No such user ziel@example.com')
|
|
->and($box->fresh()->last_verified_at)->toBeNull();
|
|
} finally {
|
|
$server->stop();
|
|
}
|
|
});
|
|
|
|
it('actually attempts TLS when SSL is configured on a non-standard port, instead of silently going out in the clear', function () {
|
|
// The exact trap this task names: Laravel 13's MailManager reads
|
|
// 'scheme', not 'encryption' (MailManager.php:196). An 'encryption' key
|
|
// is silently ignored, and MailManager falls back to choosing the scheme
|
|
// from the port number alone — smtps only for port 465, smtp otherwise.
|
|
// Configured for SSL on a non-465 port (a real EU hoster's submission
|
|
// port with implicit TLS, say), that bug would connect in PLAIN TEXT
|
|
// while the console still reported TLS.
|
|
//
|
|
// FakeSmtpServer only ever speaks plain text, so it can tell the two
|
|
// apart: a correct 'scheme' => 'smtps' opens an implicit-TLS connection
|
|
// this server cannot answer and the send FAILS; the buggy 'encryption'
|
|
// key would default to plain 'smtp' on this non-465 port and let it
|
|
// succeed instead. Verified by hand this fails in ~0.2s, not a hang —
|
|
// OpenSSL rejects the server's plain-text greeting as an invalid TLS
|
|
// record immediately.
|
|
$server = FakeSmtpServer::succeeding();
|
|
|
|
try {
|
|
Settings::set('mail.host', '127.0.0.1');
|
|
Settings::set('mail.port', $server->port); // deliberately not 465
|
|
Settings::set('mail.encryption', 'ssl');
|
|
|
|
$box = Mailbox::factory()->create(['address' => 'no-reply@clupilot.com']);
|
|
|
|
$result = app(MailboxTester::class)->run($box, 'ziel@example.com');
|
|
|
|
expect($result['ok'])->toBeFalse();
|
|
} finally {
|
|
$server->stop();
|
|
}
|
|
});
|
|
|
|
/*
|
|
| P1: the test-send button must enforce the exact same TLS policy real
|
|
| sending does (MailboxTransportTest carries the identical pair of tests for
|
|
| the real path) — otherwise a green test-send proves nothing about what a
|
|
| queued mail would actually do. Before the fix, MailboxTester built its
|
|
| transport with the default autoTls/requireTls Symfony ships (autoTls on,
|
|
| requireTls off): against a server that never offers STARTTLS, autoTls found
|
|
| nothing to upgrade to, nothing else objected, and the send completed in the
|
|
| clear while reporting success.
|
|
*/
|
|
it('fails, rather than sending in the clear, when TLS is required but the server never offers STARTTLS', function () {
|
|
$server = FakeSmtpServer::succeeding();
|
|
|
|
try {
|
|
Settings::set('mail.host', '127.0.0.1');
|
|
Settings::set('mail.port', $server->port);
|
|
Settings::set('mail.encryption', 'tls');
|
|
|
|
$box = Mailbox::factory()->create(['address' => 'no-reply@clupilot.com']);
|
|
|
|
$result = app(MailboxTester::class)->run($box, 'ziel@example.com');
|
|
|
|
expect($result['ok'])->toBeFalse()
|
|
->and($result['error'])->toContain('TLS required but neither TLS or STARTTLS are in use.')
|
|
->and($box->fresh()->last_verified_at)->toBeNull();
|
|
} finally {
|
|
$server->stop();
|
|
}
|
|
});
|
|
|
|
it('still delivers over a plain connection when encryption is none, against a server that never offers TLS', function () {
|
|
// The control case for the test above: requireTls must stay OFF for
|
|
// 'none', or a legitimately plaintext-only relay would break the moment
|
|
// the P1 fix landed.
|
|
$server = FakeSmtpServer::succeeding();
|
|
|
|
try {
|
|
Settings::set('mail.host', '127.0.0.1');
|
|
Settings::set('mail.port', $server->port);
|
|
Settings::set('mail.encryption', 'none');
|
|
|
|
$box = Mailbox::factory()->create(['address' => 'no-reply@clupilot.com']);
|
|
|
|
$result = app(MailboxTester::class)->run($box, 'ziel@example.com');
|
|
|
|
expect($result['ok'])->toBeTrue()
|
|
->and($result['error'])->toBeNull()
|
|
->and($box->fresh()->last_verified_at)->not->toBeNull();
|
|
} finally {
|
|
$server->stop();
|
|
}
|
|
});
|
|
|
|
// --- Codex R15#5, P2 comparison pass: MailboxTransport::delegate() refuses
|
|
// host==='' and port<1 with its own explicit, loud guards BEFORE building
|
|
// anything (see its docblock: "Loud beats a silently downgraded
|
|
// connection"). MailboxTester had neither. host==='' turned out not to be a
|
|
// real divergence — confirmed by hand, not assumed: Mail::build() with a
|
|
// blank host still fails fast (a few ms, via getaddrinfo), so both paths
|
|
// already refuse, just with different wording. port<1 is different in kind,
|
|
// not merely in wording: Symfony's EsmtpTransportFactory silently
|
|
// reinterprets an invalid port as 25 rather than rejecting it (confirmed by
|
|
// hand too), so MailboxTester would have gone on to actually attempt a
|
|
// connection on port 25 — accepting a configuration the real transport
|
|
// refuses outright. If a local MTA happens to listen there (common enough on
|
|
// a self-hosted box), the test button could report success for exactly the
|
|
// send MailboxTransport would never even attempt. Only the port guard is
|
|
// added below; a host guard would be redundant with what Mail::build()
|
|
// already does correctly on its own.
|
|
|
|
it('refuses to send when the mail server port is not a positive number, rather than letting Symfony silently fall back to port 25', function () {
|
|
Settings::set('mail.host', '127.0.0.1');
|
|
Settings::set('mail.port', null); // what a stored NULL json-decodes to, then casts to 0 — mirrors MailboxTransportTest's identical scenario
|
|
|
|
$box = Mailbox::factory()->create(['address' => 'no-reply@clupilot.com']);
|
|
|
|
$result = app(MailboxTester::class)->run($box, 'ziel@example.com');
|
|
|
|
expect($result['ok'])->toBeFalse()
|
|
->and($result['error'])->toBe(__('mail_settings.test_port_not_configured'));
|
|
});
|