OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

DirectoryConnectionTest.php

15 KB
<?php

declare(strict_types=1);

use OpenNIT\Core\Database;
use OpenNIT\Directory\DirectoryConnectionManager;
use OpenNIT\Directory\DirectorySync;

/**
 * Deckt mehrere Verzeichnis-Anbindungen nebeneinander ab.
 *
 * Der Abgleich legt Personen still, die das Verzeichnis nicht mehr meldet.
 * Diese Regel war an die Art der Quelle gebunden – bei zwei LDAP-Anbindungen
 * hätte damit jeder Lauf die Personen der jeweils anderen stillgelegt, und
 * zwar lautlos: Der Lauf meldet Erfolg, die Geräte- und Ticket-Zuordnungen
 * zeigen ins Leere.
 *
 * Das ist der Fall, gegen den die Fälle hier laufen. Der zweite ist das
 * Wiederfinden: Dieselbe Kennung darf in zwei Verzeichnissen vorkommen, ohne
 * dass die eine Anbindung den Eintrag der anderen überschreibt.
 */
return static function (): void {

    $wurzel = dirname(__DIR__);
    $quelle = static fn (string $pfad): string => (string)file_get_contents($wurzel . '/' . $pfad);

    // ----------------------------------------------------------------
    // Die Abmeldung bleibt bei ihrer Anbindung
    // ----------------------------------------------------------------

    /**
     * Datenbank-Attrappe, die Personen im Speicher führt – genug, um
     * upsertPersons() gegen echte Abfragen laufen zu lassen.
     */
    $db = static function (array $personen): Database {
        return new class($personen) extends Database {
            /** @var list<array<string,mixed>> */
            public array $personen;
            /** @var list<string> */
            public array $updates = [];
            private int $naechsteId = 100;

            public function __construct(array $personen)
            {
                $this->personen = $personen;
                parent::__construct([]);
            }

            public function fetchOne(string $sql, array $params = []): ?array
            {
                if (str_contains($sql, 'connection_id = ? AND external_id = ?')) {
                    foreach ($this->personen as $p) {
                        if ((int)$p['connection_id'] === (int)$params[0] && $p['external_id'] === $params[1]) {
                            return ['id' => $p['id']];
                        }
                    }
                    return null;
                }
                if (str_contains($sql, 'source = ? AND external_id = ?')) {
                    foreach ($this->personen as $p) {
                        if ($p['source'] === $params[0] && $p['external_id'] === $params[1]) {
                            return ['id' => $p['id']];
                        }
                    }
                }
                return null;
            }

            public function fetchAll(string $sql, array $params = []): array
            {
                return [];
            }

            public function insert(string $table, array $data): int
            {
                $data['id'] = $this->naechsteId++;
                $this->personen[] = $data;
                return (int)$data['id'];
            }

            public function update(string $table, array $data, string $where, array $whereParams = []): int
            {
                return 1;
            }

            public function execute(string $sql, array $params = []): \PDOStatement
            {
                // Die Abmeldung: festhalten, wonach eingegrenzt wurde.
                if (str_contains($sql, 'SET is_active = 0')) {
                    $this->updates[] = $sql . ' || ' . implode(',', array_map('strval', $params));
                }
                return new class extends \PDOStatement {
                    public function rowCount(): int
                    {
                        return 0;
                    }
                };
            }
        };
    };

    /** Ruft upsertPersons() für eine Anbindung auf. */
    $abgleich = static function (Database $db, int $connectionId, string $source, array $liste): void {
        $ref  = new ReflectionClass(DirectorySync::class);
        $sync = $ref->newInstanceWithoutConstructor();

        foreach (['db' => $db, 'config' => []] as $name => $wert) {
            $prop = $ref->getProperty($name);
            $prop->setAccessible(true);
            $prop->setValue($sync, $wert);
        }
        $prop = $ref->getProperty('connectionId');
        $prop->setAccessible(true);
        $prop->setValue($sync, $connectionId);

        $methode = $ref->getMethod('upsertPersons');
        $methode->setAccessible(true);
        $methode->invoke($sync, $source, $liste, fn (array $r): array => $r);
    };

    test('Ein Lauf legt nur Personen der eigenen Anbindung still', function () use ($db, $abgleich) {
        // Zwei LDAP-Anbindungen nebeneinander: Der Lauf der einen darf die
        // Personen der anderen nicht anfassen.
        $datenbank = $db([
            ['id' => 1, 'connection_id' => 7, 'source' => 'ldap', 'external_id' => 'CN=Anna'],
            ['id' => 2, 'connection_id' => 8, 'source' => 'ldap', 'external_id' => 'CN=Bert'],
        ]);

        $abgleich($datenbank, 7, 'ldap', [
            ['external_id' => 'CN=Anna', 'display_name' => 'Anna'],
        ]);

        assertSame(1, count($datenbank->updates), 'Es wurde nicht genau einmal abgemeldet');
        $abmeldung = $datenbank->updates[0];

        assertTrue(str_contains($abmeldung, 'connection_id = ?'), 'Die Abmeldung grenzt nicht auf die Anbindung ein');
        assertFalse(str_contains($abmeldung, 'source = ?'), 'Die Abmeldung greift über die Art');
        assertTrue(str_contains($abmeldung, '|| 7,'), 'Es wurde nicht auf Anbindung 7 eingegrenzt');
    });

    test('Dieselbe Kennung in zwei Verzeichnissen bleibt getrennt', function () use ($db, $abgleich) {
        // Zwei Kunden können denselben DN führen. Die zweite Anbindung darf
        // den Eintrag der ersten nicht übernehmen.
        $datenbank = $db([
            ['id' => 1, 'connection_id' => 7, 'source' => 'ldap', 'external_id' => 'CN=Administrator'],
        ]);

        $abgleich($datenbank, 8, 'ldap', [
            ['external_id' => 'CN=Administrator', 'display_name' => 'Administrator (Kunde B)'],
        ]);

        // Es muss ein zweiter Eintrag entstanden sein, kein überschriebener.
        assertSame(2, count($datenbank->personen), 'Der vorhandene Eintrag wurde übernommen statt ein neuer angelegt');
        $neu = $datenbank->personen[1];
        assertSame(8, $neu['connection_id'], 'Der neue Eintrag hängt an der falschen Anbindung');
    });

    test('Eine neue Person bekommt ihre Anbindung mit', function () use ($db, $abgleich) {
        $datenbank = $db([]);
        $abgleich($datenbank, 42, 'entra', [
            ['external_id' => 'abc-123', 'display_name' => 'Neue Person'],
        ]);

        assertSame(1, count($datenbank->personen));
        assertSame(42, $datenbank->personen[0]['connection_id'], 'Die Herkunft fehlt');
        assertSame('entra', $datenbank->personen[0]['source']);
    });

    // ----------------------------------------------------------------
    // Einstellungen je Anbindung
    // ----------------------------------------------------------------

    test('Eine zweite LDAP-Anbindung erbt nichts von der ersten', function () use ($quelle) {
        // Würde die Konfiguration der Anwendung durchschlagen, liefe die
        // zweite Anbindung still gegen den Server der ersten.
        $src   = $quelle('src/Directory/DirectorySync.php');
        $start = strpos($src, 'public static function forConnection(');
        assertTrue($start !== false, 'forConnection() fehlt');
        $body = substr($src, $start, 1800);

        assertTrue(str_contains($body, "\$config['ldap'] = \$einstell;"), 'LDAP erbt die alte Konfiguration');
        assertTrue(str_contains($body, "\$sync->connectionId = "), 'Die Anbindung wird nicht mitgegeben');
    });

    test('Eine Anbindung ohne Attribut-Zuordnung läuft ohne Warnungen', function () use ($quelle) {
        // Die Anbindung speichert nur, was ausgefüllt ist – die frühere
        // Konfigurationsdatei trug dagegen jeden Schlüssel. Ein Lesezugriff mit
        // `?:` auf einen fehlenden Schlüssel meldet eine Warnung, und die
        // Ausgabe verhindert die Weiterleitung nach dem Abgleich.
        $src = $quelle('src/Directory/DirectorySync.php');

        assertSame(
            0,
            preg_match_all("/\\\$this->config\\['ldap'\\]\\['\\w+'\\]\\s*\\?:/", $src),
            'Ein LDAP-Schlüssel wird ohne Ausweichwert gelesen'
        );
    });

    test('Die Art einer bestehenden Anbindung lässt sich nicht umstellen', function () use ($quelle) {
        // Die Kennungen der übernommenen Personen gehören zur Art; ein Wechsel
        // ließe sie ins Leere zeigen.
        $ctrl  = $quelle('src/Controllers/Admin/DirectoryAdminController.php');
        $start = strpos($ctrl, 'public function connectionSave(');
        $body  = substr($ctrl, (int)$start, 2200);
        assertTrue(str_contains($body, "\$typ = (string)\$bisher['type'];"), 'Die Art wird aus dem Formular übernommen');

        $tpl = $quelle('templates/admin/directory/connection_form.php');
        assertTrue(str_contains($tpl, "\$c !== null ? 'disabled' : ''"), 'Das Formular lässt die Art umstellen');
    });

    // ----------------------------------------------------------------
    // Übernahme der früheren Einzelanbindung
    // ----------------------------------------------------------------

    test('Eine hinterlegte Anbindung wird nur übernommen, solange keine steht', function () use ($quelle) {
        // Sonst überschriebe die alte Konfigurationsdatei bei jedem Aufruf,
        // was seither in der Oberfläche eingerichtet wurde.
        $src   = $quelle('src/Directory/DirectoryConnectionManager.php');
        $start = strpos($src, 'public function adoptLegacyConfig(');
        $body  = substr($src, (int)$start, 1400);

        assertTrue(
            str_contains($body, '$this->einstellungen($vorhanden) !== []'),
            'Eine eingerichtete Anbindung wird überschrieben'
        );
        assertTrue(str_contains($body, 'return false;'), 'Es gibt keinen Rückweg ohne Änderung');
    });

    test('Die Übernahme läuft auch ohne Besuch der Einstellungsseite', function () use ($quelle) {
        // Der Zeitplan darf nicht darauf warten, dass jemand die Seite öffnet.
        $runner = $quelle('src/Directory/DirectorySyncRunner.php');
        assertTrue(str_contains($runner, 'adoptLegacyConfig('), 'Der Lauf übernimmt die frühere Anbindung nicht');
    });

    // ----------------------------------------------------------------
    // Der Lauf über mehrere Anbindungen
    // ----------------------------------------------------------------

    test('Eine unerreichbare Anbindung kostet nicht die übrigen', function () use ($quelle) {
        $src   = $quelle('src/Directory/DirectorySyncRunner.php');
        $start = strpos($src, 'foreach ($anbindungen as $anbindung)');
        $body  = substr($src, (int)$start, 1200);

        assertTrue(str_contains($body, 'catch (\\Throwable $e)'), 'Ein Fehler bricht den ganzen Lauf ab');
        assertTrue(str_contains($body, '$geglueckt'), 'Es wird nicht gezählt, was durchkam');

        // Gescheitert ist der Lauf nur, wenn keine einzige Anbindung durchkam.
        assertTrue(str_contains($src, "'ok'          => \$geglueckt > 0,"), 'Ein einzelner Fehlschlag gilt als Gesamtfehler');
    });

    test('Jeder Lauf steht an seiner Anbindung und im Protokoll', function () use ($quelle) {
        $src = $quelle('src/Directory/DirectorySyncRunner.php');
        assertTrue(str_contains($src, '$manager->recordRun($id, $ok, $meldung);'), 'Das Ergebnis bleibt nicht an der Anbindung');
        assertTrue(str_contains($src, "'directory_sync_failed'"), 'Ein Fehlschlag wird nicht protokolliert');
    });

    // ----------------------------------------------------------------
    // Entfernen
    // ----------------------------------------------------------------

    test('Das Entfernen einer Anbindung löscht keine Personen', function () use ($quelle) {
        // Sie hängen an Geräten, Tickets und Anträgen; ein Löschen risse dort
        // Lücken, die niemand mehr füllen kann.
        $src   = $quelle('src/Directory/DirectoryConnectionManager.php');
        $start = strpos($src, 'public function delete(int $id): int');
        $body  = substr($src, (int)$start, 700);

        assertTrue(str_contains($body, 'SET is_active = 0, connection_id = NULL'), 'Die Personen werden nicht stillgelegt');
        assertFalse(str_contains($body, "delete('directory_persons'"), 'Die Personen werden gelöscht');
        assertTrue(str_contains($body, "delete('directory_connections', 'id = ?'"), 'Die Anbindung bleibt stehen');
    });

    // ----------------------------------------------------------------
    // Ablage
    // ----------------------------------------------------------------

    test('Der Schlüssel folgt der Anbindung, nicht der Art', function () use ($quelle) {
        $sql = $quelle('database/migrations/223_directory_connections.sql');

        assertTrue(str_contains($sql, 'CREATE TABLE IF NOT EXISTS `directory_connections`'), 'Die Tabelle fehlt');
        assertTrue(str_contains($sql, 'ADD COLUMN `connection_id`'), 'Die Herkunft fehlt an der Person');
        assertTrue(str_contains($sql, 'DROP INDEX `uq_person_external`'), 'Der alte Schlüssel bleibt stehen');
        assertTrue(
            str_contains($sql, 'ADD UNIQUE KEY `uq_person_connection` (`connection_id`, `external_id`(180))'),
            'Der neue Schlüssel fehlt'
        );

        // Vorhandene Personen bekommen ihre Herkunft, sonst stünde der neue
        // Schlüssel über lauter NULL und schützte nichts.
        assertTrue(str_contains($sql, 'SET p.`connection_id` = c.`id`'), 'Bestehende Personen bleiben ohne Herkunft');
        assertTrue(str_contains($sql, 'WHERE p.`connection_id` IS NULL'), 'Die Zuordnung ist nicht wiederholbar');
    });

    test('Die Anbindungen sind im Backend erreichbar und gesichert', function () use ($quelle) {
        $app = $quelle('src/Core/Application.php');
        foreach (['connectionForm', 'connectionSave', 'connectionSync', 'connectionDelete'] as $aktion) {
            assertTrue(str_contains($app, 'DirectoryAdminController@' . $aktion), 'Route fehlt: ' . $aktion);
        }
        // Die feste Strecke muss vor der {id}-Strecke stehen.
        $neu = strpos($app, "/admin/directory/connections/new");
        $id  = strpos($app, "/admin/directory/connections/{id}/edit");
        assertTrue($neu !== false && $id !== false && $neu < $id, 'Die {id}-Route fängt „new" ab');

        foreach (['connections.php', 'connection_form.php'] as $datei) {
            $tpl = $quelle('templates/admin/directory/' . $datei);
            assertTrue(str_contains($tpl, 'name="_csrf"'), $datei . ': Ohne Token schlägt das Absenden fehl');
        }
    });
};