<?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;
use Illuminate\Support\Facades\Exceptions;

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

// ---- Eine Sperre, die nur in der Datenbank steht, darf nicht schweigen ----

it('meldet, wenn die Regel nicht auf den Host kam', function () {
    // Auf einem Host, der noch das alte Regelwerk ohne die Mengen traegt,
    // scheitert `nft add element` bei JEDEM Versuch — und beide Aufrufer
    // verwerfen den Rueckgabewert. Die Sperre stuende dann in Datenbank,
    // Portal, Konsole und in der Mail an den Kunden als aktiv und in der
    // Firewall nie.
    Exceptions::fake();

    $shell = new FakeRemoteShell;
    $shell->defaultResult(\App\Services\Ssh\CommandResult::failure(1, 'Error: No such file or directory'));
    app()->instance(\App\Services\Ssh\RemoteShell::class, $shell);
    $host = Host::factory()->active()->create(['name' => 'pve-fsn-07', 'ssh_host_key' => 'SHA256:abc']);

    expect(app(HostFirewall::class)->block($host, '203.0.113.7', 3600))->toBeFalse();

    // Konkrete Klasse, nicht Throwable: der Fake vergleicht den Typ des
    // Closure-Parameters EXAKT mit get_class() — mit Throwable trifft er nie
    // (siehe pest-toThrow-Falle, dasselbe Muster).
    Exceptions::assertReported(fn (RuntimeException $e) => str_contains($e->getMessage(), 'pve-fsn-07')
        && str_contains($e->getMessage(), '203.0.113.7'));
});

it('meldet nichts, solange die Regel ankommt', function () {
    // Die Gegenprobe: eine Meldung, die immer kommt, ist keine.
    Exceptions::fake();

    app()->instance(\App\Services\Ssh\RemoteShell::class, new FakeRemoteShell);
    $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);

    app(HostFirewall::class)->block($host, '203.0.113.7', 3600);

    Exceptions::assertNothingReported();
});

// ---- Der Riegel: was hier hineingeht, wird als root auf dem Host ausgefuehrt ----

it('fuehrt bei einer Adresse mit Semikolon gar nichts aus und gibt false zurueck', function () {
    // `PhpseclibRemoteShell::run()` reicht die zusammengebaute Zeile an /bin/sh
    // als ROOT auf dem Proxmox-Host. Ein Semikolon in der Adresse waere dort
    // ein zweiter Befehl — der Dienst darf sich nicht darauf verlassen, dass
    // sein Aufrufer sauber war.
    $shell = new FakeRemoteShell;
    app()->instance(\App\Services\Ssh\RemoteShell::class, $shell);
    $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);

    $result = app(HostFirewall::class)->block($host, '1.2.3.4; rm -rf /', 3600);

    expect($result)->toBeFalse()
        ->and($shell->recorded())->toBe([])
        // Nicht einmal verbunden: was nicht ausgefuehrt werden darf, braucht
        // auch keine Wurzel-Sitzung.
        ->and($shell->connections)->toBe([]);
});

it('weist auch beim Entsperren eine Adresse ab, die keine ist', function () {
    $shell = new FakeRemoteShell;
    app()->instance(\App\Services\Ssh\RemoteShell::class, $shell);
    $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);

    expect(app(HostFirewall::class)->release($host, '$(id)'))->toBeFalse()
        ->and($shell->recorded())->toBe([]);
});

// ---- Buendeln: eine Verbindung je Host, nicht eine je Sperre ----

it('traegt drei Adressen desselben Hosts in EINER Verbindung ein', function () {
    // Bei dreissig Sperren waren das dreissig Handshakes pro Minute, seriell,
    // auf demselben Arbeiter, der die bezahlte Kundenbereitstellung faehrt —
    // und dreissig Sperren gibt es genau waehrend eines Angriffs.
    $shell = new FakeRemoteShell;
    app()->instance(\App\Services\Ssh\RemoteShell::class, $shell);
    $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);

    $ok = app(HostFirewall::class)->blockMany($host, [
        '203.0.113.7' => 1200,
        '203.0.113.8' => 600,
        '198.51.100.9' => 3600,
    ]);

    expect($ok)->toBeTrue()
        ->and($shell->connections)->toHaveCount(1)
        ->and($shell->recorded())->toHaveCount(1);

    // Und jede Adresse behaelt ihre EIGENE Restlaufzeit — eine gemeinsame
    // Dauer fuers Buendel waere bequemer und wuerde genau die Zusicherung
    // verwaessern, fuer die es einen eigenen Test gibt.
    expect($shell->ran('203.0.113.7 timeout 1200s'))->toBeTrue()
        ->and($shell->ran('203.0.113.8 timeout 600s'))->toBeTrue()
        ->and($shell->ran('198.51.100.9 timeout 3600s'))->toBeTrue();
});

it('trennt v4 und v6 in zwei Befehle, aber nicht in zwei Verbindungen', 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)->blockMany($host, ['203.0.113.7' => 60, '2001:db8::1' => 60]);

    expect($shell->connections)->toHaveCount(1)
        ->and($shell->recorded())->toHaveCount(2)
        ->and($shell->ran('clupilot_blocked { 203.0.113.7 timeout 60s }'))->toBeTrue()
        ->and($shell->ran('clupilot_blocked6 { 2001:db8::1 timeout 60s }'))->toBeTrue();
});

it('fuehrt aus einem Buendel gar nichts aus, wenn eine einzige Adresse keine ist', function () {
    $shell = new FakeRemoteShell;
    app()->instance(\App\Services\Ssh\RemoteShell::class, $shell);
    $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);

    $ok = app(HostFirewall::class)->blockMany($host, [
        '203.0.113.7' => 60,
        '1.2.3.4; nft flush ruleset' => 60,
    ]);

    expect($ok)->toBeFalse()
        ->and($shell->recorded())->toBe([]);
});

it('nimmt mehrere Adressen desselben Hosts in EINER Verbindung 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)->releaseMany($host, ['203.0.113.7', '203.0.113.8', '2001:db8::1']);

    // Zwei Befehle, weil v4 und v6 in getrennten Mengen leben — aber EINE
    // Verbindung. Das ist der Grund, aus dem es diese Methode gibt.
    expect($shell->connections)->toHaveCount(1)
        ->and($shell->ran('delete element inet clupilot_filter clupilot_blocked { 203.0.113.7, 203.0.113.8 }'))->toBeTrue()
        ->and($shell->ran('delete element inet clupilot_filter clupilot_blocked6 { 2001:db8::1 }'))->toBeTrue();
});

it('traegt jedem Loeschbefehl sein eigenes Auffangnetz mit', function () {
    // nft scheitert beim Loeschen eines Elements, das es nicht gibt — und genau
    // das ist der Normalfall: der Kernel hat es selbst herausgenommen
    // (`flags timeout`), oder es stand nie drin, weil der Host beim Sperren
    // nicht erreichbar war. Ohne das `|| true` meldete der Abgleich in
    // ScanForIntrusions bei JEDEM Lauf einen Fehlschlag, und der Mensch am
    // Bildschirm bekaeme „Server nicht erreichbar" zu lesen, obwohl alles
    // stimmt.
    //
    // Geprueft am erzeugten BEFEHL, nicht am Verhalten des Fakes: dieser fuehrt
    // keine Shell aus, `|| true` kann er also gar nicht abbilden. Wirksam ist
    // es genau dort, wo der Befehl hingeht — in /bin/sh auf dem Host.
    $shell = new FakeRemoteShell;
    app()->instance(\App\Services\Ssh\RemoteShell::class, $shell);
    $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);

    app(HostFirewall::class)->releaseMany($host, ['203.0.113.7', '2001:db8::1']);

    $loeschbefehle = array_filter($shell->recorded(), fn ($b) => str_contains($b, 'delete element'));

    expect($loeschbefehle)->toHaveCount(2);

    foreach ($loeschbefehle as $befehl) {
        expect($befehl)->toEndWith('2>/dev/null || true');
    }
});

it('meldet sehr wohl, wenn der Host gar 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']);

    expect(app(HostFirewall::class)->release($host, '203.0.113.7'))->toBeFalse();
});
