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('to')->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(); });