CluPilotCloud/tests/Feature/Security/HostFirewallTest.php

86 lines
4.0 KiB
PHP

<?php // tests/Feature/Security/HostFirewallTest.php
use App\Models\Host;
use App\Provisioning\Steps\Host\SecureHostFirewall;
use App\Services\Security\HostFirewall;
use App\Services\Ssh\FakeRemoteShell;
it('stellt die Sperrregel UNTER die Regel fuer bestehende Verbindungen', function () {
// Das ist die eigentliche Zusage des ganzen Systems: „wer drin ist, bleibt
// drin". Sie hängt an dieser Reihenfolge und an nichts sonst. Geprüft am
// ERZEUGTEN Regelwerk, nicht am Quelltext — ein Test gegen den Quelltext
// wäre auch dann grün, wenn die Zeilen im Ergebnis anders herum stünden.
$shell = new FakeRemoteShell;
app()->instance(\App\Services\Ssh\RemoteShell::class, $shell);
app(SecureHostFirewall::class)->execute(
\App\Models\ProvisioningRun::factory()->forHost(Host::factory()->create())->create()
);
$regelwerk = $shell->files()['/etc/nftables.conf'] ?? '';
$established = strpos($regelwerk, 'ct state established,related accept');
$sperre = strpos($regelwerk, '@clupilot_blocked');
expect($established)->not->toBeFalse()
->and($sperre)->not->toBeFalse()
->and($established)->toBeLessThan($sperre);
// Beide Mengen, und beide mit Ablaufzeit — ohne `flags timeout` nimmt
// nftables die Zeitangabe beim Eintragen gar nicht an. Strukturell geprüft,
// je Menge einzeln: ein bloßer substr_count('flags timeout') über den
// GANZEN Text (Kommentare eingeschlossen) belegt nur „die Phrase kommt
// zweimal vor", nicht „beide set-Blöcke tragen die Ablaufzeit" — er bliebe
// grün, wenn clupilot_blocked6 die Zeitoption verlöre, während dieselbe
// Phrase zufällig zweimal in Prosa stünde.
expect($regelwerk)->toMatch('/set clupilot_blocked \{\s*type ipv4_addr\s*flags timeout\s*\}/')
->and($regelwerk)->toMatch('/set clupilot_blocked6 \{\s*type ipv6_addr\s*flags timeout\s*\}/');
});
it('traegt eine Adresse mit Ablaufzeit ein und nimmt sie wieder heraus', function () {
$shell = new FakeRemoteShell;
app()->instance(\App\Services\Ssh\RemoteShell::class, $shell);
$host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);
app(HostFirewall::class)->block($host, '203.0.113.7', 3600);
app(HostFirewall::class)->release($host, '203.0.113.7');
expect($shell->ran('add element inet clupilot_filter clupilot_blocked { 203.0.113.7 timeout 3600s }'))->toBeTrue()
->and($shell->ran('delete element inet clupilot_filter clupilot_blocked { 203.0.113.7 }'))->toBeTrue();
});
it('waehlt fuer eine IPv6-Adresse die zweite Menge', function () {
$shell = new FakeRemoteShell;
app()->instance(\App\Services\Ssh\RemoteShell::class, $shell);
$host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);
app(HostFirewall::class)->block($host, '2001:db8::1', 3600);
expect($shell->ran('clupilot_blocked6 { 2001:db8::1 timeout 3600s }'))->toBeTrue();
});
it('gibt false zurueck statt zu werfen, wenn der Host beim Sperren nicht erreichbar ist', function () {
// Trägt Aufgabe 4: der Sperrdatensatz entsteht auch dann in der Datenbank
// und wird beim nächsten Lauf erneut eingetragen. Eine Ausnahme hier würde
// stattdessen den ganzen Zeitplan-Auftrag mitreißen, der block() aufruft.
$shell = new FakeRemoteShell;
$shell->failConnect = true;
app()->instance(\App\Services\Ssh\RemoteShell::class, $shell);
$host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);
$result = app(HostFirewall::class)->block($host, '203.0.113.7', 3600);
expect($result)->toBeFalse();
});
it('gibt false zurueck statt zu werfen, wenn der Host beim Entsperren nicht erreichbar ist', function () {
$shell = new FakeRemoteShell;
$shell->failConnect = true;
app()->instance(\App\Services\Ssh\RemoteShell::class, $shell);
$host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);
$result = app(HostFirewall::class)->release($host, '203.0.113.7');
expect($result)->toBeFalse();
});