CluPilotCloud/tests/Feature/Mail/MailboxTransportTest.php

473 lines
23 KiB
PHP

<?php
use App\Mail\Transport\MailboxTransport;
use App\Models\Mailbox;
use App\Services\Mail\MailPurpose;
use App\Support\Settings;
use Illuminate\Mail\Transport\ArrayTransport;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Symfony\Component\Mailer\Exception\TransportException;
use Symfony\Component\Mailer\Transport\NullTransport;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream;
use Symfony\Component\Mailer\Transport\TransportInterface;
use Tests\Support\FakeSmtpServer;
beforeEach(function () {
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
clearMailboxSeed();
Settings::set('mail.host', 'mail.example.test');
Settings::set('mail.port', 587);
Settings::set('mail.encryption', 'tls');
});
/**
* Reach into the same private delegate() that send() itself calls.
*
* __toString() and send() decide "log, array, null, or a real mailbox"
* through describe()/delegate() — two DIFFERENT methods. A mutation that
* broke only delegate() once passed the whole suite, because every test
* checked (string) $transport, which never calls it. Where a behavioural
* proof (actually sending, see the log-guard test below) is impractical
* without real I/O, this is how those tests reach the one that matters.
*/
function mailboxDelegate(TransportInterface $transport): TransportInterface
{
return Closure::bind(fn () => $this->delegate(), $transport, MailboxTransport::class)();
}
it('registers a mailer for every purpose', function () {
// The five entries exist as plain config — no query has run to build them.
foreach (MailPurpose::ALL as $purpose) {
expect(config('mail.mailers.cp_'.$purpose))
->toMatchArray(['transport' => 'mailbox', 'purpose' => $purpose]);
}
});
it('defines every purpose mailer as a plain literal, so building it cannot touch the database', function () {
// A slice with no parenthesis in it cannot contain a function call —
// env(), config(), DB::table(), all of them need one. Checked at the
// SOURCE level, the same technique DisplayTimezoneTest and IconLayoutTest
// use for a property of the file rather than of one particular run.
$source = (string) file_get_contents(config_path('mail.php'));
foreach (MailPurpose::ALL as $purpose) {
preg_match('/\'cp_'.preg_quote($purpose, '/').'\'\s*=>\s*(\[[^\]]*\])/', $source, $matches);
expect($matches)->toHaveCount(2, "cp_{$purpose} is missing or not a plain array literal");
expect($matches[1])->not->toContain('(')
->and($matches[1])->toContain("'mailbox'")
->and($matches[1])->toContain("'{$purpose}'");
}
});
it('resolves the purpose to the address of the mailbox it is currently mapped to', function () {
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'smtp'); // delivery on, as on live
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
expect($transport)->toBeInstanceOf(MailboxTransport::class)
->and((string) $transport)->toContain('support@clupilot.com');
});
it('marks the DSN as unconfigured rather than showing a healthy-looking address with no password', function () {
// The exact harm the finding named: describe() only ever showed the
// address, so a mailbox with an active row, a real address, and no
// password looked identical to a working one — right up until send()
// threw. The address stays visible (an operator needs to know WHICH
// mailbox is broken); the marker is what stops it from reading as fine.
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com', 'password' => null]);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'smtp');
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
expect((string) $transport)
->toContain('support@clupilot.com')
->toContain('unconfigured');
// Agreement, not just a matching label: the DSN's warning must correspond
// to send() actually refusing, not to a marker painted on independently.
expect(fn () => mailboxDelegate($transport))->toThrow(RuntimeException::class);
});
it('logs instead of delivering while MAIL_MAILER is log', function () {
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'log');
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
// Still our transport — but it hands the message to the log, so a seeder
// run cannot mail a real customer from a development machine.
expect((string) $transport)->toContain('log');
});
/*
| LOAD-BEARING for the MAIL_MAILER=log guard — this is the one that actually
| SENDS, so a broken guard cannot hide behind a __toString() that still looks
| right. Settings point at a closed loopback port: nothing listens on
| 127.0.0.1:1, so a real attempt is refused in milliseconds — no DNS, no
| dependency on this environment's network egress policy, and the failure
| (a thrown TransportException) names the actual harm rather than a class
| mismatch.
*/
it('actually sends through the log transport, not SMTP, while MAIL_MAILER is log', function () {
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'log');
Settings::set('mail.host', '127.0.0.1');
Settings::set('mail.port', 1);
Log::shouldReceive('channel')->andReturnSelf();
Log::shouldReceive('debug')->once();
Mail::mailer('cp_support')->raw('body', function ($message) {
$message->to('customer@example.test')->subject('Test');
});
});
it('does not deliver for real when MAIL_MAILER is array, the suite-wide test default', function () {
// phpunit.xml forces MAIL_MAILER=array for the whole suite. Task 5 wires
// real mailables to these mailers next — the first feature test that
// triggers one without Mail::fake() must not open a real socket just
// because 'array' is not the exact string 'log'.
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'array');
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
expect(mailboxDelegate($transport))->toBeInstanceOf(ArrayTransport::class);
});
it('does not deliver for real when MAIL_MAILER is not configured at all', function () {
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', null);
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
expect(mailboxDelegate($transport))->toBeInstanceOf(NullTransport::class);
});
it('reuses the same delegate until the mailbox credentials actually change', function () {
// The headline feature of the class. A worker builds its mailer once and
// keeps it for hours, so a password the operator corrects in the console
// must reach the NEXT mail without a restart — but not open a new SMTP
// connection for every single message when nothing changed either.
$box = Mailbox::factory()->create([
'key' => 'support', 'address' => 'support@clupilot.com', 'password' => 'first-password',
]);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'smtp');
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
$first = mailboxDelegate($transport);
$second = mailboxDelegate($transport);
expect($second)->toBe($first); // nothing changed — no new connection per mail
$box->update(['password' => 'second-password']);
$third = mailboxDelegate($transport);
expect($third)->not->toBe($first); // the operator's correction takes effect immediately
});
// --- Codex R15#4, P1b: an unauthenticated relay must not have credentials
// forced onto it. smtpUsername() always returns something non-empty (it
// falls back to the address), so a delegate built without checking
// authenticates first would still call setUsername() with a real value —
// which alone is enough to make Symfony attempt AUTH against any server
// that advertises it, something a trusted no-auth relay was never asked to
// answer.
it('never calls setUsername or setPassword on the delegate when the mailbox does not authenticate', function () {
Mailbox::factory()->create([
'key' => 'support', 'address' => 'support@clupilot.com',
'username' => null, 'password' => null, 'authenticates' => false,
]);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'smtp');
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
$delegate = mailboxDelegate($transport);
expect($delegate)->toBeInstanceOf(EsmtpTransport::class)
->and($delegate->getUsername())->toBe('')
->and($delegate->getPassword())->toBe('');
});
it('still sets a real username and password on the delegate when the mailbox does authenticate', function () {
// The control case for the test above: the fix must not stop
// authenticated mailboxes from authenticating.
Mailbox::factory()->create([
'key' => 'support', 'address' => 'support@clupilot.com',
'username' => null, 'password' => 'a-real-password', 'authenticates' => true,
]);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'smtp');
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
$delegate = mailboxDelegate($transport);
expect($delegate->getUsername())->toBe('support@clupilot.com') // smtpUsername()'s address fallback
->and($delegate->getPassword())->toBe('a-real-password');
});
it('rebuilds the delegate when authenticates toggles, even though host, port, encryption and password all stay the same', function () {
// reuses the same delegate...' above proves the fingerprint reacts to a
// PASSWORD change; this proves it also reacts to authenticates itself —
// without that, an operator unchecking "requires a password" on a
// long-running queue worker would keep sending through the OLD delegate,
// which already has setUsername()/setPassword() baked in from before the
// toggle, still attempting AUTH the operator just turned off.
$box = Mailbox::factory()->create([
'key' => 'support', 'address' => 'support@clupilot.com',
'username' => null, 'password' => 'unchanged-password', 'authenticates' => true,
]);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'smtp');
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
$first = mailboxDelegate($transport);
expect($first->getUsername())->not->toBe('');
$box->update(['authenticates' => false]);
$second = mailboxDelegate($transport);
expect($second)->not->toBe($first)
->and($second->getUsername())->toBe('');
});
// --- Codex R15#5, P2 comparison pass: the fingerprint line above reads
// (string) $box->password UNCONDITIONALLY, even when authenticates is false
// and setUsername()/setPassword() are never called on the result. $box
// ->password decrypts under SECRETS_KEY, so a mailbox that once
// authenticated, stored a password, and later had authenticates unchecked —
// leaving the old ciphertext in place, since an empty password field in
// EditMailbox means "leave it alone" — would crash a real send the moment
// SECRETS_KEY became unusable, purely to compute a cache key for a value it
// was never going to use. Same root cause as the MailboxTester fix above.
it('does not decrypt a stale password to build the delegate for an unauthenticated mailbox, even without a usable cipher', function () {
$box = Mailbox::factory()->create([
'key' => 'support', 'address' => 'support@clupilot.com',
'password' => 'a-stale-password', 'authenticates' => false,
]);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'smtp');
config()->set('admin_access.secrets_key', '');
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
// No expect(fn () => ...)->not->toThrow(Throwable::class) here on
// purpose: Throwable is an interface, and Pest's toThrow() falls back to
// treating a class-string it cannot class_exists() as a MESSAGE
// substring to search for instead — so that matcher silently passes
// regardless of what is thrown (confirmed by hand: a bare `throw new
// RuntimeException('boom')` inside such a closure still reports as not
// having thrown). Calling delegate() directly and asserting on its
// return type instead means an uncaught exception fails this test the
// ordinary way, with its real stack trace, rather than being silently
// absorbed by a matcher that never engages.
$delegate = mailboxDelegate($transport);
expect($delegate)->toBeInstanceOf(EsmtpTransport::class)
->and($delegate->getUsername())->toBe('')
->and($delegate->getPassword())->toBe('');
});
it('opens a plain connection with a STARTTLS upgrade for the submission port', function () {
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'smtp');
Settings::set('mail.port', 587);
Settings::set('mail.encryption', 'tls');
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
$delegate = mailboxDelegate($transport);
expect((string) $delegate)->toBe('smtp://mail.example.test:587')
->and($delegate)->toBeInstanceOf(EsmtpTransport::class)
// The P1 fix: 'tls' is a REQUIREMENT, not a suggestion Symfony is
// free to skip when the server omits STARTTLS. autoTls must stay on
// too — without it, the STARTTLS upgrade this mode depends on would
// never even be attempted, and requireTls would then fail every send.
->and($delegate->isTlsRequired())->toBeTrue()
->and($delegate->isAutoTls())->toBeTrue();
});
it('opens an implicit-TLS connection for the SMTPS port, never a hanging STARTTLS one', function () {
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'smtp');
Settings::set('mail.port', 465);
Settings::set('mail.encryption', 'ssl');
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
$delegate = mailboxDelegate($transport);
expect((string) $delegate)->toBe('smtps://mail.example.test')
->and($delegate)->toBeInstanceOf(EsmtpTransport::class)
->and($delegate->isTlsRequired())->toBeTrue();
});
it('disables both implicit TLS and the opportunistic STARTTLS upgrade when encryption is none', function () {
// 'none' is a deliberate, explicit choice — not the absence of one — so
// it must not silently upgrade just because a server happens to offer
// STARTTLS. autoTls defaults to true in Symfony; this is the one mode
// that has to turn it off rather than merely leave requireTls false.
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'smtp');
Settings::set('mail.port', 587);
Settings::set('mail.encryption', 'none');
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
$delegate = mailboxDelegate($transport);
expect((string) $delegate)->toBe('smtp://mail.example.test:587')
->and($delegate)->toBeInstanceOf(EsmtpTransport::class)
->and($delegate->isAutoTls())->toBeFalse()
->and($delegate->isTlsRequired())->toBeFalse();
});
/*
| LOAD-BEARING for the P1 security fix — property assertions above prove the
| transport is CONFIGURED correctly, but the recurring defect on this branch
| has been tests that pass whether or not the code is right, so this proves
| the configuration actually changes what goes out on the wire. FakeSmtpServer
| never advertises STARTTLS (see its own docblock), which is exactly the
| shape of the threat Codex named: a server — or a network attacker sitting in
| front of one — that omits the capability. Before the fix, 'tls' passed
| false as EsmtpTransport's third constructor argument and left requireTls at
| its default of false: autoTls found no STARTTLS to use, nothing else
| objected, and the send completed in the clear. after the fix, the same
| conversation must throw instead.
*/
it('refuses to send in the clear when TLS is required but the server never offers STARTTLS', function () {
$server = FakeSmtpServer::succeeding();
try {
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'smtp');
Settings::set('mail.host', '127.0.0.1');
Settings::set('mail.port', $server->port);
Settings::set('mail.encryption', 'tls');
expect(fn () => Mail::mailer('cp_support')->raw('body', function ($message) {
$message->to('customer@example.test')->subject('Test');
}))->toThrow(TransportException::class, 'TLS required but neither TLS or STARTTLS are in use.');
} finally {
$server->stop();
}
});
it('still sends 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
// this fix landed.
$server = FakeSmtpServer::succeeding();
try {
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'smtp');
Settings::set('mail.host', '127.0.0.1');
Settings::set('mail.port', $server->port);
Settings::set('mail.encryption', 'none');
expect(fn () => Mail::mailer('cp_support')->raw('body', function ($message) {
$message->to('customer@example.test')->subject('Test');
}))->not->toThrow(Throwable::class);
} finally {
$server->stop();
}
});
it('refuses an implicit-TLS connection against a server that only ever speaks plain text', function () {
// The 'ssl' mirror of the two tests above: FakeSmtpServer only ever
// speaks plain text (see its docblock), so a correct implicit-TLS attempt
// must fail the OpenSSL handshake immediately rather than falling back to
// a plaintext conversation.
$server = FakeSmtpServer::succeeding();
try {
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'smtp');
Settings::set('mail.host', '127.0.0.1');
Settings::set('mail.port', $server->port);
Settings::set('mail.encryption', 'ssl');
expect(fn () => Mail::mailer('cp_support')->raw('body', function ($message) {
$message->to('customer@example.test')->subject('Test');
}))->toThrow(TransportException::class);
} finally {
$server->stop();
}
});
it('refuses to send when the mail server host is blank', function () {
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'smtp');
Settings::set('mail.host', ''); // Tasks 6-7 are what write this — nothing has yet
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
expect(fn () => mailboxDelegate($transport))->toThrow(RuntimeException::class);
});
it('refuses to send when the mail server port is not a positive number', function () {
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'smtp');
Settings::set('mail.port', null); // what a stored NULL json-decodes to, then casts to 0
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
expect(fn () => mailboxDelegate($transport))->toThrow(RuntimeException::class);
});
/*
| A property check, not a real hang, on purpose: MailboxTesterTest carries
| the real end-to-end timed proof (a genuinely unroutable host, elapsed time
| asserted) for the 10s user-facing bound — this file's own convention for
| the identical situation is "property assertions... prove the transport is
| CONFIGURED correctly" (see the TLS policy tests above), reserving an actual
| timed connection for where mirroring the wire behaviour, not just the
| config, earns its cost. A real 30s hang here would buy nothing beyond that:
| SocketStream::setTimeout() is a plain property setter with no branching to
| miss, and 30 extra real seconds on every suite run is a poor trade for
| proving what MailManager's own use of the identical call already lets
| MailboxTesterTest verify behaviourally.
*/
it('bounds the delegate to a 30 second socket timeout, so a queue worker cannot hang toward the 60s default', function () {
Mailbox::factory()->create(['key' => 'support', 'address' => 'support@clupilot.com']);
Settings::set(MailPurpose::settingKey(MailPurpose::SUPPORT), 'support');
config()->set('mail.default', 'smtp');
Settings::set('mail.host', '10.255.255.1'); // never answers — see MailboxTesterTest; irrelevant here, nothing connects
Settings::set('mail.port', 587);
$transport = Mail::mailer('cp_support')->getSymfonyTransport();
$delegate = mailboxDelegate($transport);
expect($delegate)->toBeInstanceOf(EsmtpTransport::class)
->and($delegate->getStream())->toBeInstanceOf(SocketStream::class)
// 30.0, not the 60.0 that ini's default_socket_timeout would leave
// getTimeout() reporting if delegate() had never called setTimeout()
// at all — the exact way this fix could silently do nothing.
->and($delegate->getStream()->getTimeout())->toBe(30.0);
});