CluPilotCloud/tests/Feature/Security/SecurityBlockMailTest.php

170 lines
7.3 KiB
PHP

<?php
// tests/Feature/Security/SecurityBlockMailTest.php
use App\Mail\SecurityBlockMail;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Mailbox;
use App\Models\SecurityBlock;
use App\Services\Mail\MailPurpose;
use App\Services\Mail\MailRoute;
use App\Services\Security\BlockAddress;
use App\Support\Settings;
use Illuminate\Support\Facades\Mail;
// Blank slate statt der migrationseigenen fuenf Postfaecher (no-reply,
// support, billing, office, info): der letzte Test unten legt selbst eines
// mit dem Schluessel 'no-reply' an, und das kollidiert sonst mit
// mailboxes.key's Unique-Constraint gegen die von RefreshDatabase stehen
// gelassene Saat (siehe MailRoutingTest, derselbe Helfer aus tests/Pest.php).
beforeEach(fn () => clearMailboxSeed());
it('schickt dem Inhaber eine Mail, wenn seine Instanz gesperrt wird', function () {
Mail::fake();
$instance = Instance::factory()->create(['status' => 'active', 'vmid' => 101]);
app(BlockAddress::class)->forInstance($instance, '203.0.113.7', 12);
Mail::assertQueued(SecurityBlockMail::class, fn ($m) => $m->hasTo($instance->customer->email));
});
it('schickt hoechstens eine Mail je Instanz und Stunde', function () {
// Ein Angreifer, der Adressen durchwechselt, erzeugt sonst zwanzig Mails —
// und die zwanzigste liest niemand mehr.
Mail::fake();
$instance = Instance::factory()->create(['status' => 'active', 'vmid' => 101]);
app(BlockAddress::class)->forInstance($instance, '203.0.113.7', 12);
app(BlockAddress::class)->forInstance($instance, '203.0.113.8', 12);
app(BlockAddress::class)->forInstance($instance, '203.0.113.9', 12);
Mail::assertQueuedCount(1);
});
it('laesst die Sperre stehen, wenn die Mail scheitert', function () {
// Zustellung ist nicht die Bedingung fuer Schutz. Eine Sperre, die von einem
// kaputten Postfach abhinge, waere genau dann weg, wenn ohnehin schon etwas
// im Argen liegt.
Mail::shouldReceive('mailer')->andThrow(new RuntimeException('Postfach kaputt'));
$instance = Instance::factory()->create(['status' => 'active', 'vmid' => 101]);
$block = app(BlockAddress::class)->forInstance($instance, '203.0.113.7', 12);
expect($block)->not->toBeNull()
->and(SecurityBlock::count())->toBe(1);
});
it('drosselt nicht, wenn gar keine Mail hinausging', function () {
// Sonst schweigt eine Instanz eine volle Stunde, obwohl NULL Meldungen
// ankamen — genau dann, wenn ohnehin schon etwas klemmt.
Mail::fake();
// Ein Postfach, das den ERSTEN Aufruf von Mail::to() wirft und danach an
// die echte Fake-Implementierung durchreicht — so laesst sich "der erste
// Versandversuch scheitert, der zweite geht durch" nachstellen, ohne die
// Zusicherung am Ende (Mail::assertQueued) aufzugeben.
$fake = Mail::getFacadeRoot();
Mail::swap(new class($fake)
{
private int $calls = 0;
public function __construct(private readonly object $inner) {}
public function __call(string $name, array $arguments): mixed
{
if ($name === 'to' && ++$this->calls === 1) {
throw new RuntimeException('Postfach kaputt');
}
return $this->inner->{$name}(...$arguments);
}
});
$instance = Instance::factory()->create(['status' => 'active', 'vmid' => 101]);
// erster Versuch: Versand scheitert
app(BlockAddress::class)->forInstance($instance, '203.0.113.7', 12);
// zweiter Versuch kurz darauf: Versand geht durch
app(BlockAddress::class)->forInstance($instance, '203.0.113.8', 12);
// Die zweite Sperre muss eine Mail erzeugen, obwohl die erste eine
// Stunde 'verbraucht' zu haben schien.
Mail::assertQueued(SecurityBlockMail::class);
});
it('geht standardmaessig aus dem System-Postfach und folgt der Wegwahl', function () {
Mailbox::factory()->create(['key' => 'no-reply', 'address' => 'no-reply@clupilot.com', 'active' => true]);
$info = Mailbox::factory()->create(['key' => 'info', 'active' => true]);
Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'no-reply');
$block = SecurityBlock::factory()->create();
expect((new SecurityBlockMail($block))->envelope()->from->address)->toBe('no-reply@clupilot.com');
Settings::set(MailRoute::settingKey('security-block'), 'info');
expect((new SecurityBlockMail($block))->envelope()->from->address)->toBe($info->address);
});
// ---- Host-Sperren: an jeden aktiven Betreiber mit hosts.manage (Koordinator-Entscheidung, Fix-Runde 1) ----
it('schickt bei einer Host-Sperre an jeden Betreiber, der Hosts verwalten darf', function () {
Mail::fake();
// Owner und Admin haben hosts.manage (seed_roles_and_permissions), Support
// nicht — siehe database/migrations/2026_07_25_133900_seed_roles_and_permissions.php.
$darf = operator('Admin');
$darfNicht = operator('Support');
$host = Host::factory()->create();
app(BlockAddress::class)->forHost($host, '203.0.113.7', 12);
// Beide Haelften: der Zustaendige bekommt sie, der Unzustaendige nicht.
Mail::assertQueued(SecurityBlockMail::class, fn ($m) => $m->hasTo($darf->email));
Mail::assertNotQueued(SecurityBlockMail::class, fn ($m) => $m->hasTo($darfNicht->email));
});
it('nennt in der Host-Mail den Host und fuehrt NICHT ins Portal', function () {
// Der Betreiber hat im Portal kein Konto, und Host-Sperren erscheinen dort
// nie — ein Knopf auf portal.security fuehrt ihn also zwangslaeufig ins
// Leere. Und ohne den Hostnamen stuende in einer Meldung an jemanden mit
// einem Dutzend Maschinen nicht, welche gemeint ist.
$host = Host::factory()->active()->create(['name' => 'pve-fsn-07']);
$block = SecurityBlock::factory()->forHost($host)->create(['ip' => '203.0.113.42']);
$html = (new SecurityBlockMail($block))->render();
expect($html)->toContain('pve-fsn-07')
->and($html)->toContain('203.0.113.42')
->and($html)->toContain(route('admin.hosts.show', $host))
->and($html)->not->toContain(route('portal.security'))
// Und der Kundentext ist weg, nicht bloss ergaenzt.
->and($html)->not->toContain(__('security.mail_intro', ['ip' => $block->ip, 'attempts' => $block->attempts]));
expect((new SecurityBlockMail($block))->envelope()->subject)
->toBe(__('security.mail_subject_host', ['host' => 'pve-fsn-07']));
});
it('laesst die Instanz-Mail unveraendert im Portal landen', function () {
// Die Gegenprobe: der neue Host-Zweig darf den bestehenden Fall nicht
// mitnehmen.
$instance = Instance::factory()->create(['status' => 'active', 'vmid' => 101]);
$block = SecurityBlock::factory()->create(['instance_id' => $instance->id, 'host_id' => null]);
$html = (new SecurityBlockMail($block))->render();
expect($html)->toContain(route('portal.security'))
->and($html)->toContain(__('security.mail_action'))
->and($html)->not->toContain(__('security.mail_action_host'));
});
it('sperrt auch dann, wenn es keinen zustaendigen Betreiber gibt', function () {
Mail::fake();
// Keine Betreiber mit hosts.manage — kein Empfaenger, kein Fehler, aber
// sehr wohl eine Sperre. Schutz haengt nicht an Zustellung.
$host = Host::factory()->create();
expect(app(BlockAddress::class)->forHost($host, '203.0.113.7', 12))->not->toBeNull();
Mail::assertNothingQueued();
});