OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

LicenseComparisonTest.php

16 KB
<?php

declare(strict_types=1);

/**
 * Lizenz-Abgleich – gekaufte Plätze gegen tatsächliche Installation.
 *
 * Der Abgleich taugt nur, solange er nichts behauptet, was er nicht weiß.
 * Zweierlei gehört deshalb geprüft: dass die Zuordnung Lizenz ↔ Programm
 * gepflegt und nicht geraten wird (ein Vorschlag bleibt ein Vorschlag), und
 * dass aus einer Lizenz ohne Platzbegrenzung keine Überziehung wird.
 *
 * Dazu die Übernahme aus Entra: Wer welchen Platz hält, ersetzt der Abgleich
 * bei jedem Lauf – aber ausschließlich in seinen eigenen Zeilen. Eine von Hand
 * gepflegte Zuordnung darf dabei nicht verschwinden.
 */

use OpenNIT\Core\Database;
use OpenNIT\Software\LicenseComparison as Vergleich;

return static function (): void {

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

    /** Ein Vergleich, der jede Abfrage samt Werten festhält. */
    $vergleichMit = static function (array $antwort = []) {
        $db = new class ($antwort) extends Database {
            public string $sql = '';
            /** @var array<int,mixed> */
            public array $params = [];
            /** @var list<array{0:string,1:array<int,mixed>}> */
            public array $writes = [];

            public function __construct(private array $antwort)
            {
            }

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

            public function fetchOne(string $sql, array $params = []): ?array
            {
                $this->sql = $sql;
                return $this->antwort[0] ?? null;
            }

            public function execute(string $sql, array $params = []): \PDOStatement
            {
                $this->writes[] = [$sql, $params];
                return (new \PDO('sqlite::memory:'))->query('SELECT 1');
            }

            public function delete(string $table, string $where, array $params = []): int
            {
                $this->writes[] = ['DELETE ' . $table . ' ' . $where, $params];
                return 1;
            }

            public function escapeLike(string $wert): string
            {
                return $wert;
            }
        };

        return [new Vergleich($db), $db];
    };

    // ----------------------------------------------------------------
    // Die Bewertung
    // ----------------------------------------------------------------

    test('Mehr installiert als gekauft ist eine Überziehung', function () {
        $d = Vergleich::differenz(['seats' => 10, 'installationen' => 14]);
        assertSame('fehlend', $d['ton']);
        assertSame(-4, $d['wert']);
        assertTrue(str_contains($d['text'], '4'), 'Die Zahl fehlt im Text: ' . $d['text']);
    });

    test('Freie Plätze werden als frei benannt', function () {
        $d = Vergleich::differenz(['seats' => 10, 'installationen' => 6]);
        assertSame('frei', $d['ton']);
        assertSame(4, $d['wert']);
    });

    test('Genau aufgebraucht ist weder frei noch überzogen', function () {
        $d = Vergleich::differenz(['seats' => 10, 'installationen' => 10]);
        assertSame('genau', $d['ton']);
        assertSame(0, $d['wert']);
    });

    test('Ohne Platzbegrenzung gibt es keine Überziehung', function () {
        // Eine Lizenz ohne Sitzangabe ist unbegrenzt, nicht überzogen – sonst
        // stünde jede solche Lizenz dauerhaft auf Rot.
        foreach ([0, null, ''] as $plaetze) {
            $d = Vergleich::differenz(['seats' => $plaetze, 'installationen' => 500]);
            assertSame('unbegrenzt', $d['ton'], 'Bei seats=' . var_export($plaetze, true));
            assertSame(0, $d['wert']);
        }
    });

    test('Ohne Installation bleibt die Lizenz unauffällig', function () {
        $d = Vergleich::differenz(['seats' => 10, 'installationen' => 0]);
        assertSame('frei', $d['ton']);
        assertSame(10, $d['wert']);
    });

    // ----------------------------------------------------------------
    // Die Vorschläge
    // ----------------------------------------------------------------

    test('Gattungsbegriffe taugen nicht als Suchwort', function () {
        // „Business" oder „Standard" fänden jedes zweite Programm.
        $worte = Vergleich::begriffe('Microsoft 365 Business Standard');
        foreach (['microsoft', 'business', 'standard'] as $unerwuenscht) {
            assertFalse(in_array($unerwuenscht, $worte, true), $unerwuenscht . ' ist ein Suchwort geworden');
        }
    });

    test('Kurze Wörter bleiben außen vor', function () {
        foreach (Vergleich::begriffe('Adobe CC für PC und Mac 365') as $wort) {
            assertTrue(mb_strlen($wort) >= 4, 'Zu kurz: ' . $wort);
        }
    });

    test('Der kennzeichnende Teil bleibt erhalten', function () {
        assertTrue(in_array('acrobat', Vergleich::begriffe('Adobe Acrobat Pro'), true), 'acrobat fehlt');
        assertTrue(in_array('visio', Vergleich::begriffe('Visio Plan 2'), true), 'visio fehlt');
    });

    test('Eine Jahreszahl im Lizenznamen bleibt ein Suchwort in Textform', function () use ($vergleichMit) {
        // Gesammelt wird über Array-Schlüssel, und ein Schlüssel aus einer
        // reinen Zahl ist in PHP ein Integer. Weiter geht es an escapeLike(),
        // das eine Zeichenkette erwartet.
        foreach (Vergleich::begriffe('Office Standard 2016') as $wort) {
            assertSame('string', gettype($wort), 'Kein Text: ' . var_export($wort, true));
        }

        [$vergleich, $db] = $vergleichMit();
        $vergleich->suggestions(['name' => 'Office Standard 2016', 'product' => '']);
        assertTrue(in_array('%2016%', $db->params, true), 'Die Jahreszahl wurde nicht gesucht: ' . json_encode($db->params));
    });

    test('Ohne brauchbares Wort wird nichts vorgeschlagen', function () use ($vergleichMit) {
        // Sonst entstünde eine Abfrage ohne Bedingung, die alles zurückgäbe.
        [$vergleich, $db] = $vergleichMit();

        assertSame([], $vergleich->suggestions(['name' => 'Pro', 'product' => 'App']));
        assertSame('', $db->sql, 'Es wurde trotzdem abgefragt');
    });

    test('Ein Vorschlag zeigt nur, was zu keiner Lizenz gehört', function () use ($vergleichMit) {
        // Ein Programm hängt an genau einer Lizenz; bereits zugeordnete
        // dürfen nicht ein zweites Mal angeboten werden.
        [$vergleich, $db] = $vergleichMit();

        $vergleich->suggestions(['name' => 'Acrobat Reader', 'product' => '']);
        assertTrue(str_contains($db->sql, 'l.title_id IS NULL'), 'Bereits zugeordnete werden mit vorgeschlagen');
        assertTrue(str_contains($db->sql, 'JOIN software_installations'), 'Auch nicht installierte werden vorgeschlagen');
    });

    test('Suchwörter stehen als Platzhalter, nicht im SQL', function () use ($vergleichMit) {
        [$vergleich, $db] = $vergleichMit();

        $vergleich->suggestions(['name' => "Acrobat' OR 1=1 --", 'product' => '']);
        assertFalse(str_contains($db->sql, '1=1'), 'Die Eingabe landete im SQL');
        assertTrue($db->params !== [], 'Es wurden keine Werte gebunden');
    });

    // ----------------------------------------------------------------
    // Nichts wird geraten
    // ----------------------------------------------------------------

    test('Die Gegenüberstellung zeigt nur gepflegte Zuordnungen', function () use ($vergleichMit) {
        // Ein automatisch erratener Bezug ergäbe eine Zahl, die niemand
        // nachprüft und die still falsch wäre.
        [$vergleich, $db] = $vergleichMit();

        $vergleich->overview();
        assertTrue(str_contains($db->sql, 'JOIN license_software_titles'), 'Es wird ohne Zuordnung verglichen');
        assertFalse(str_contains($db->sql, 'LIKE'), 'Die Gegenüberstellung rät über Namensähnlichkeit');
    });

    test('Archivierte Lizenzen zählen nicht mit', function () use ($vergleichMit) {
        [$vergleich, $db] = $vergleichMit();

        $vergleich->overview();
        assertTrue(str_contains($db->sql, "li.status <> 'archived'"), 'Archivierte stehen im Abgleich');
    });

    test('Systembestandteile stehen nicht in der Liste des Unzugeordneten', function () use ($vergleichMit) {
        // Sonst bestünde sie aus Treibern und Updates.
        [$vergleich, $db] = $vergleichMit();

        $vergleich->unlinked();
        assertTrue(str_contains($db->sql, 't.is_system = 0'), 'Systembestandteile werden mitgezählt');
        assertTrue(str_contains($db->sql, 'l.title_id IS NULL'), 'Auch Zugeordnetes steht in der Liste');
    });

    test('Das Verbinden ist wiederholbar', function () use ($vergleichMit) {
        [$vergleich, $db] = $vergleichMit();

        $vergleich->link(5, 9, 3);
        assertSame(1, count($db->writes));
        assertTrue(str_contains($db->writes[0][0], 'ON DUPLICATE KEY UPDATE'), 'Ein zweiter Klick schlägt fehl');
        assertSame([5, 9, 3], $db->writes[0][1]);
    });

    // ----------------------------------------------------------------
    // Übernahme aus Entra
    // ----------------------------------------------------------------

    test('Der Abgleich fasst nur seine eigenen Zuordnungen an', function () use ($quelle) {
        // Eine von Hand gepflegte Zuordnung darf ein Lauf nicht wegräumen.
        $sync = $quelle('src/Directory/EntraLicenseSync.php');

        $von       = (int)strpos($sync, 'private function syncAssignments(');
        assertTrue($von > 0, 'Die Übernahme fehlt');
        $abschnitt = substr($sync, $von, 6000);

        // Jede Löschanweisung für sich betrachten: Sie muss ihre eigene
        // Herkunftsbedingung mitbringen.
        // Der Rumpf reicht bis zum Ende des jeweiligen Zeichenketten-Literals.
        preg_match_all('/DELETE FROM license_assignments(.*?)"/s', $abschnitt, $treffer);
        assertTrue(count($treffer[1]) > 0, 'Es wird nie aufgeräumt');

        foreach ($treffer[1] as $nr => $rumpf) {
            assertTrue(
                str_contains($rumpf, "source = 'entra'"),
                'Löschung ' . ($nr + 1) . ' läuft ohne Herkunftsbegrenzung: ' . trim($rumpf)
            );
            assertTrue(
                str_contains($rumpf, "subject_type = 'person'"),
                'Löschung ' . ($nr + 1) . ' nimmt Gerätezuordnungen nicht aus: ' . trim($rumpf)
            );
        }
    });

    test('Die Übernahme läuft nur auf ausdrücklichen Wunsch', function () use ($quelle) {
        // Die Namen sind eine Angabe über Personen; die Zahl steht ohnehin da.
        $sync = $quelle('src/Directory/EntraLicenseSync.php');

        assertTrue(
            (bool)preg_match("/if \(\!empty\(\\\$this->config\['licenses'\]\['entra_sync_assignments'\]\)\)/", $sync),
            'Die Übernahme hängt an keinem Schalter'
        );

        $controller = $quelle('src/Controllers/Admin/LicenseSyncController.php');
        assertTrue(
            str_contains($controller, "\$cfg['licenses']['entra_sync_assignments'] = \$enabled &&"),
            'Der Schalter wirkt auch ohne eingeschalteten Lizenz-Abgleich'
        );
    });

    test('Ohne eingelesene Lizenz oder bekannte Person passiert nichts', function () use ($quelle) {
        // Personen legt der Verzeichnisabgleich an, nicht dieser.
        $sync = $quelle('src/Directory/EntraLicenseSync.php');

        $von       = (int)strpos($sync, 'private function syncAssignments(');
        $abschnitt = substr($sync, $von, 2500);

        assertSame(2, substr_count($abschnitt, 'return 0;'), 'Ein leerer Fall läuft weiter');
        assertFalse(str_contains($abschnitt, 'INSERT INTO directory_persons'), 'Es werden Personen angelegt');
    });

    test('Die Herkunft einer Zuordnung steht im Schema', function () use ($wurzel) {
        $migration = null;
        foreach ((array)glob($wurzel . '/database/migrations/*.sql') as $datei) {
            $inhalt = (string)file_get_contents((string)$datei);
            if (str_contains($inhalt, 'license_assignments') && str_contains($inhalt, 'ADD COLUMN `source`')) {
                $migration = $inhalt;
                break;
            }
        }

        assertTrue($migration !== null, 'Keine Migration führt die Herkunft ein');
        assertTrue(str_contains($migration, "ENUM('manual','entra')"), 'Die Herkunft kennt nicht beide Fälle');
        // Bestehende Zeilen sind von Hand entstanden und dürfen nicht als
        // abgeglichen gelten – sonst räumt der erste Lauf sie weg.
        assertTrue(str_contains($migration, "DEFAULT 'manual'"), 'Bestehende Zeilen gälten als abgeglichen');
    });

    test('Die Verbindung Lizenz ↔ Programm steht im Schema', function () use ($wurzel) {
        $migration = null;
        foreach ((array)glob($wurzel . '/database/migrations/*.sql') as $datei) {
            $inhalt = (string)file_get_contents((string)$datei);
            if (str_contains($inhalt, 'license_software_titles')) {
                $migration = $inhalt;
                break;
            }
        }

        assertTrue($migration !== null, 'Keine Migration legt die Verbindung an');
        assertTrue(str_contains($migration, 'PRIMARY KEY (`license_id`, `title_id`)'), 'Dieselbe Verbindung ließe sich doppelt anlegen');
        assertTrue(str_contains($migration, 'fk_lst_license'), 'Die Verbindung hängt nicht an der Lizenz');
        assertTrue(str_contains($migration, 'fk_lst_title'), 'Die Verbindung hängt nicht am Programm');
    });

    // ----------------------------------------------------------------
    // Erreichbarkeit
    // ----------------------------------------------------------------

    test('Der Abgleich ist nur mit Recht erreichbar', function () use ($quelle) {
        $app = $quelle('src/Core/Application.php');

        assertTrue(
            (bool)preg_match("/'\/licenses\/abgleich',\s*'LicenseController@compare',\s*\['auth' => true, 'capability' => 'view_licenses'\]/", $app),
            'Der Abgleich ist ohne Rechteprüfung erreichbar'
        );
        foreach (['linkTitle', 'unlinkTitle'] as $aktion) {
            assertTrue(
                (bool)preg_match('/LicenseController@' . $aktion . "',\s*\['auth' => true, 'capability' => 'manage_licenses'\]/", $app),
                'Die Aktion ' . $aktion . ' prüft das Pflegerecht nicht'
            );
        }

        // Die feste Adresse muss vor der {id}-Route stehen.
        $fest = (int)strpos($app, "'/licenses/abgleich'");
        $id   = (int)strpos($app, "'/licenses/{id}',");
        assertTrue($fest > 0 && $fest < $id, 'Der Router fasst „abgleich" als Lizenz-Kennung auf');
    });

    test('Ohne Software-Inventar bleibt der Abgleich verschlossen', function () use ($quelle) {
        $controller = $quelle('src/Controllers/LicenseController.php');

        $von       = (int)strpos($controller, 'private function vergleich(');
        assertTrue($von > 0, 'Der Zugang fehlt');
        $abschnitt = substr($controller, $von, 500);

        assertTrue(str_contains($abschnitt, "modules['software']"), 'Das Modul wird nicht geprüft');
        assertTrue(str_contains($abschnitt, "can('view_software')"), 'Das Leserecht wird nicht geprüft');
        assertTrue(str_contains($abschnitt, 'return null;'), 'Ohne Modul wird trotzdem verglichen');
    });

    test('Jedes Formular der neuen Seiten trägt den Token', function () use ($quelle) {
        foreach (['templates/licenses/compare.php', 'templates/licenses/show.php'] as $datei) {
            $inhalt    = $quelle($datei);
            $formulare = preg_match_all('/<form method="POST"/i', $inhalt);
            $token     = substr_count($inhalt, 'name="_csrf"');
            assertTrue($formulare === 0 || $token >= $formulare,
                'Ein Formular in ' . $datei . ' sendet keinen Token');
        }
    });

    test('Eine übernommene Zuordnung lässt sich nicht von Hand aufheben', function () use ($quelle) {
        // Der nächste Lauf trüge sie wieder ein; geändert wird sie in Entra.
        $zeige = $quelle('templates/licenses/show.php');

        assertTrue(
            (bool)preg_match("/\(\\\$z\['source'\] \?\? 'manual'\) === 'entra'/", $zeige),
            'Die Herkunft wird in der Liste nicht unterschieden'
        );
    });
};