170 lines
7.3 KiB
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('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();
|
|
});
|