222 lines
10 KiB
PHP
222 lines
10 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\Transport\NullTransport;
|
|
use Symfony\Component\Mailer\Transport\TransportInterface;
|
|
|
|
beforeEach(function () {
|
|
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
|
|
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
|
|
});
|
|
|
|
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();
|
|
|
|
expect((string) mailboxDelegate($transport))->toBe('smtp://mail.example.test:587');
|
|
});
|
|
|
|
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();
|
|
|
|
expect((string) mailboxDelegate($transport))->toBe('smtps://mail.example.test');
|
|
});
|
|
|
|
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);
|
|
});
|