OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

DnsCompletionTest.php

16 KB
<?php

declare(strict_types=1);

use OpenNIT\Content\DnsManager;
use OpenNIT\Core\Database;
use OpenNIT\Dns\AgentScripts;
use OpenNIT\Dns\RecordDiff;

/**
 * Deckt die drei Punkte ab, mit denen die Beta-Kennzeichnung des DNS-Imports
 * faellt: Aenderungsverlauf je Zone, weitere Quellen neben Windows-DNS und der
 * Abgleich mit der IP-Verwaltung.
 */
return static function (): void {

    $wurzel = dirname(__DIR__);
    $src    = static fn (string $datei): string => (string)file_get_contents($wurzel . $datei);

    /** Kurzform fuer einen Record. */
    $rec = static fn (string $name, string $typ, string $daten, ?int $ttl = 3600): array =>
        ['name' => $name, 'type' => $typ, 'data' => $daten, 'ttl' => $ttl];

    // ----------------------------------------------------------------
    // Aenderungsverlauf
    // ----------------------------------------------------------------

    test('Ein unveraenderter Satz erzeugt keine Eintraege', function () use ($rec) {
        // Der Agent laeuft regelmaessig; ohne das stuende nach jedem Lauf
        // die ganze Zone im Verlauf.
        $a = [$rec('www', 'A', '10.0.0.1'), $rec('@', 'MX', '10 mail')];
        // Auch in anderer Reihenfolge.
        $b = [$rec('@', 'MX', '10 mail'), $rec('www', 'A', '10.0.0.1')];

        assertSame([], RecordDiff::vergleiche($a, $b));
    });

    test('Ein umgezogener Record wird als Aenderung gefuehrt', function () use ($rec) {
        // Nicht als Loeschung plus Neuanlage - das waere fuer den Leser
        // dieselbe Information in unbrauchbarer Form.
        $diff = RecordDiff::vergleiche([$rec('www', 'A', '10.0.0.1')], [$rec('www', 'A', '10.0.0.2')]);

        assertSame(1, count($diff));
        assertSame('changed', $diff[0]['change_type']);
        assertSame('10.0.0.1', $diff[0]['old_data']);
        assertSame('10.0.0.2', $diff[0]['new_data']);
    });

    test('Eine reine TTL-Aenderung wird erkannt', function () use ($rec) {
        $diff = RecordDiff::vergleiche(
            [$rec('www', 'A', '10.0.0.1', 3600)],
            [$rec('www', 'A', '10.0.0.1', 300)]
        );

        assertSame(1, count($diff));
        assertSame('changed', $diff[0]['change_type']);
        assertSame(3600, $diff[0]['old_ttl']);
        assertSame(300, $diff[0]['new_ttl']);
    });

    test('Mehrere Werte eines Namens gelten nicht als staendige Aenderung', function () use ($rec) {
        // Mehrere A-Records fuer einen Namen sind ein ueblicher Aufbau. Ein
        // Vergleich nur ueber Name und Typ wuerde sie bei jedem Lauf melden.
        $vorher  = [$rec('lb', 'A', '10.0.0.1', 60), $rec('lb', 'A', '10.0.0.2', 60)];
        $nachher = [$rec('lb', 'A', '10.0.0.1', 60), $rec('lb', 'A', '10.0.0.2', 60), $rec('lb', 'A', '10.0.0.3', 60)];

        $diff = RecordDiff::vergleiche($vorher, $nachher);
        assertSame(1, count($diff), 'Mehr als der neue Wert gemeldet');
        assertSame('added', $diff[0]['change_type']);
        assertSame('10.0.0.3', $diff[0]['new_data']);
    });

    test('Hinzugefuegt und entfernt werden benannt', function () use ($rec) {
        $diff = RecordDiff::vergleiche(
            [$rec('alt', 'A', '10.0.0.9'), $rec('bleibt', 'A', '10.0.0.1')],
            [$rec('bleibt', 'A', '10.0.0.1'), $rec('neu', 'CNAME', 'bleibt')]
        );

        $arten = array_column($diff, 'change_type');
        sort($arten);
        assertSame(['added', 'removed'], $arten);
    });

    test('Ein doppelter identischer Record erzeugt keinen Unterschied', function () use ($rec) {
        // Zweimal derselbe Wert ist in einer Zone bedeutungslos; sonst
        // erschiene beim naechsten Lauf eine Aenderung, die niemand vornahm.
        assertSame([], RecordDiff::vergleiche(
            [$rec('www', 'A', '10.0.0.1', 60)],
            [$rec('www', 'A', '10.0.0.1', 60), $rec('www', 'A', '10.0.0.1', 60)]
        ));
    });

    test('Der Erstimport einer Zone erzeugt keinen Verlauf', function () use ($src) {
        // Sonst stuenden beim ersten Lauf saemtliche Records als
        // "hinzugefuegt" im Verlauf.
        $code = $src('/src/Content/DnsManager.php');
        $von  = strpos($code, 'public function importZone');
        $block = substr($code, (int)$von, 4000);

        assertTrue(str_contains($block, '$istErstimport = $vorher === []'), 'Erstimport nicht erkannt');
        assertTrue(str_contains($block, '$istErstimport ? [] : RecordDiff::vergleiche'), 'Erstimport erzeugt Verlauf');
    });

    test('Verglichen wird gegen dieselbe Form, die gespeichert wird', function () use ($src) {
        // Sonst scheitert der Vergleich an Schreibweisen (Leerzeichen,
        // Gross-/Kleinschreibung des Typs) statt an echten Aenderungen.
        $code = $src('/src/Content/DnsManager.php');
        $von  = strpos($code, 'public function importZone');
        $block = substr($code, (int)$von, 4000);

        $norm   = strpos($block, '$normalisiert[] = [');
        $insert = strpos($block, "INSERT INTO dns_records");
        $diff   = strpos($block, 'RecordDiff::vergleiche');
        assertTrue($norm !== false && $norm < $insert, 'Erst speichern, dann normalisieren');
        assertTrue($diff > $insert, 'Vergleich vor dem Speichern');
        assertTrue(str_contains($block, "\$rec['name'], \$rec['type'], \$rec['data'], \$rec['ttl']"), 'Gespeichert wird etwas anderes');
    });

    test('Der Verlauf wird begrenzt aufbewahrt', function () use ($src) {
        $code = $src('/src/Content/DnsManager.php');
        $von  = strpos($code, 'private function raeumeVerlaufAuf');
        $block = substr($code, (int)$von, 700);

        assertTrue(str_contains($block, 'if ($tage <= 0)'), '0 schaltet die Begrenzung nicht ab');
        assertTrue(str_contains($block, 'DELETE FROM dns_zone_changes'), 'Es wird nichts entfernt');
    });

    // ----------------------------------------------------------------
    // Weitere Quellen
    // ----------------------------------------------------------------

    test('Die Herkunft einer Zone wird gefuehrt', function () use ($src) {
        assertSame(['windows', 'bind', 'powerdns', 'agent'], DnsManager::QUELLEN);

        $code = $src('/src/Content/DnsManager.php');
        $von  = strpos($code, 'public function importZone');
        $block = substr($code, (int)$von, 2000);

        // Eine unbekannte Angabe faellt auf 'agent' zurueck, statt gespeichert
        // zu werden - sonst waere die Spalte nicht auswertbar.
        assertTrue(str_contains($block, 'in_array($quelle, self::QUELLEN, true)'), 'Quelle ungeprueft');
        assertTrue(str_contains($block, "\$quelle = 'agent'"), 'Kein Ausweichwert');
        assertTrue(str_contains($block, 'source = VALUES(source)'), 'Quelle wird nie aktualisiert');
    });

    test('Beide neuen Agenten sind gueltiges Python', function () use ($wurzel) {
        // Ein Syntaxfehler faellt sonst erst auf dem Nameserver auf.
        $tmp = sys_get_temp_dir() . '/opennit-dns-agent-test';
        @mkdir($tmp, 0700, true);

        foreach (['bind' => AgentScripts::bind('https://opennit.example'),
                  'powerdns' => AgentScripts::powerdns('https://opennit.example')] as $name => $inhalt) {
            $pfad = $tmp . '/' . $name . '.py';
            file_put_contents($pfad, $inhalt);

            // Ohne Python im Bild bleibt wenigstens die Platzhalter-Prüfung.
            @exec('python3 -m py_compile ' . escapeshellarg($pfad) . ' 2>&1', $ausgabe, $code);
            if ($code === 127) {
                continue; // kein Python vorhanden
            }
            assertSame(0, $code, "Syntaxfehler im {$name}-Agenten: " . implode("\n", $ausgabe));
        }

        @array_map('unlink', glob($tmp . '/*') ?: []);
    });

    test('Die Agenten tragen die eigene Adresse und die Quelle', function () {
        $bind = AgentScripts::bind('https://opennit.example/');
        $pdns = AgentScripts::powerdns('https://opennit.example/');

        foreach (['bind' => $bind, 'powerdns' => $pdns] as $quelle => $skript) {
            assertFalse(str_contains($skript, '__SERVER_URL__'), "Platzhalter blieb stehen: {$quelle}");
            assertTrue(str_contains($skript, 'https://opennit.example'), "Adresse fehlt: {$quelle}");
            // Ein Trailing-Slash wuerde zu einer doppelten Schraegstrich-URL führen.
            assertFalse(str_contains($skript, 'https://opennit.example/\''), "Schraegstrich nicht entfernt: {$quelle}");
            assertTrue(str_contains($skript, "'source': '{$quelle}'"), "Quelle nicht gesetzt: {$quelle}");
            // Alle Agenten liefern an denselben Endpunkt.
            assertTrue(str_contains($skript, '/api/ex/dns/zones'), "Falscher Endpunkt: {$quelle}");
        }
    });

    test('Ein leerer Abruf loescht nicht den Bestand', function () {
        // Prune wird nur mitgeschickt, wenn wirklich Zonen gelesen wurden.
        foreach ([AgentScripts::bind('https://x'), AgentScripts::powerdns('https://x')] as $skript) {
            assertTrue(
                str_contains($skript, 'damit nichts geloescht wird'),
                'Leerer Abruf wird gesendet'
            );
        }
    });

    test('Die Agenten sind im Backend erreichbar', function () use ($src) {
        $routen = $src('/src/Core/Application.php');
        foreach (['download-windows', 'download-bind', 'download-powerdns'] as $pfad) {
            assertTrue(str_contains($routen, "'/admin/dns-agent/{$pfad}'"), "Route fehlt: {$pfad}");
        }

        $tpl = $src('/templates/admin/dns-agent/index.php');
        foreach (['download-bind', 'download-powerdns'] as $pfad) {
            assertTrue(str_contains($tpl, $pfad), "Kein Download-Knopf: {$pfad}");
        }
    });

    // ----------------------------------------------------------------
    // Abgleich mit der IP-Verwaltung
    // ----------------------------------------------------------------

    test('Freie Adressen gelten nicht als Auffaelligkeit', function () use ($src) {
        // In der Richtung IPAM -> DNS: eine freie Adresse soll gerade keinen
        // Namen tragen. Beide Abfragen dieser Richtung - Liste und Zaehlung -
        // nehmen sie aus.
        $code = $src('/src/Dns/IpamComparison.php');
        assertSame(2, substr_count($code, "a.status <> 'free'"), 'Freie Adressen nicht in beiden Abfragen ausgenommen');

        // In der Gegenrichtung DNS -> IPAM aber bewusst nicht: eine als frei
        // gefuehrte Adresse ist der IP-Verwaltung trotzdem bekannt und damit
        // keine Auffaelligkeit.
        $von   = strpos($code, 'public function namenOhneAdresse');
        $block = substr($code, (int)$von, 1200);
        assertFalse(str_contains($block, "a.status"), 'Bekannte Adresse faelschlich als unbekannt gemeldet');
        assertTrue(str_contains($block, 'NOT EXISTS'), 'Falsche Pruefung');
    });

    test('Verglichen werden nur Records, die eine Adresse tragen', function () use ($src) {
        $code = $src('/src/Dns/IpamComparison.php');
        assertTrue(str_contains($code, "ADRESS_TYPEN = ['A', 'AAAA']"), 'Falsche Typen');
        assertTrue(str_contains($code, 'MAX_TREFFER'), 'Keine Obergrenze');
    });

    test('Ohne IP-Verwaltung bleibt der Abgleich still', function () use ($src) {
        $code = $src('/src/Dns/IpamComparison.php');
        assertSame(3, substr_count($code, 'catch (\Throwable)'), 'Fehlende Tabellen schlagen durch');

        // Die Seite erscheint nur bei aktivem Modul.
        $ctrl = $src('/src/Controllers/DnsController.php');
        assertTrue(str_contains($ctrl, "config('modules.ipam')"), 'Modul ungeprueft');

        $tpl = $src('/templates/dns/ipam_check.php');
        assertTrue(str_contains($tpl, 'ist nicht aktiv'), 'Kein Hinweis bei abgeschaltetem Modul');
    });

    test('Der Abgleich aendert nichts', function () use ($src) {
        // Beide Richtungen koennen voellig in Ordnung sein - eine automatische
        // Korrektur waere hier falsch.
        $code = $src('/src/Dns/IpamComparison.php');
        foreach (['INSERT', 'UPDATE', 'DELETE'] as $wort) {
            assertFalse((bool)preg_match('/\b' . $wort . '\s+(INTO|FROM|ipam|dns)/i', $code), "Schreibender Zugriff: {$wort}");
        }

        $tpl = $src('/templates/dns/ipam_check.php');
        assertFalse(str_contains($tpl, '<form'), 'Die Seite bietet eine Aktion an');
    });

    // ----------------------------------------------------------------
    // Zone entfernen
    // ----------------------------------------------------------------

    test('Eine Zone laesst sich samt Records und Verlauf entfernen', function () {
        $db = new class extends Database {
            public array $sql = [];
            public function __construct() {}
            public function fetchOne(string $sql, array $params = []): ?array
            {
                $this->sql[] = $sql;
                return ['id' => 7, 'name' => 'alt.example', 'dns_server' => 'dns01', 'source' => 'windows'];
            }
            public function fetchColumn(string $sql, array $params = [], int $column = 0): mixed
            {
                $this->sql[] = $sql;
                return 12;
            }
            public function execute(string $sql, array $params = []): \PDOStatement
            {
                $this->sql[] = $sql;
                return new \PDOStatement();
            }
        };

        $zone = (new DnsManager($db))->deleteZone(7);

        assertSame('alt.example', $zone['name']);
        // Fuer das Audit muss erkennbar bleiben, wie viel mit der Zone ging.
        assertSame(12, $zone['record_count']);
        assertTrue(
            (bool)preg_grep('/DELETE FROM dns_zones WHERE id = \?/', $db->sql),
            'Zone nicht geloescht'
        );
        // Records und Verlauf haengen per Fremdschluessel an der Zone.
        assertFalse((bool)preg_grep('/DELETE FROM dns_records/', $db->sql), 'Records einzeln geloescht');
    });

    test('Eine unbekannte Zone wird nicht geloescht', function () {
        $db = new class extends Database {
            public array $sql = [];
            public function __construct() {}
            public function fetchOne(string $sql, array $params = []): ?array { return null; }
            public function execute(string $sql, array $params = []): \PDOStatement
            {
                $this->sql[] = $sql;
                return new \PDOStatement();
            }
        };

        assertSame(null, (new DnsManager($db))->deleteZone(999));
        assertSame([], $db->sql, 'Es wurde trotzdem geschrieben');
    });

    test('Zonen entfernen bleibt der Verwaltung vorbehalten', function () use ($src) {
        $ctrl = $src('/src/Controllers/DnsController.php');
        assertTrue(str_contains($ctrl, "can('manage_dns'"), 'Keine Rechtepruefung');
        assertTrue(str_contains($ctrl, "'dns_zone_deleted'"), 'Loeschung ohne Audit-Eintrag');

        // Die Route selbst ist ebenfalls gebunden - nicht nur der Controller.
        $app = $src('/src/Core/Application.php');
        assertTrue(
            (bool)preg_match("#delete\('/dns/zones/\{id\}'.*'capability' => 'manage_dns'#", $app),
            'Route ungeschuetzt'
        );

        // Der Knopf erscheint nur, wenn die Person die Zone auch entfernen darf.
        $tpl = $src('/templates/dns/zone_show.php');
        assertTrue(str_contains($tpl, 'name="_method" value="DELETE"'), 'Kein DELETE-Formular');
        assertTrue(str_contains($tpl, 'name="_csrf"'), 'Formular ohne CSRF-Feld');
        assertTrue(str_contains($tpl, "!empty(\$can_manage)"), 'Knopf ungegated');
    });

    // ----------------------------------------------------------------
    // Migration
    // ----------------------------------------------------------------

    test('Die Migration ist wiederholbar', function () use ($wurzel) {
        $sql = (string)file_get_contents($wurzel . '/database/migrations/217_dns_zone_changes.sql');

        assertTrue(str_contains($sql, 'CREATE TABLE IF NOT EXISTS `dns_zone_changes`'), 'Tabelle nicht bedingt');
        assertTrue(str_contains($sql, 'INSERT IGNORE'), 'Nicht wiederholbarer Datensatz');
        assertFalse(str_contains($sql, 'TRUNCATE'), 'Zerstoerendes Statement');
        // Der Verlauf verschwindet mit seiner Zone.
        assertTrue(str_contains($sql, 'ON DELETE CASCADE'), 'Verwaiste Eintraege moeglich');
    });
};