OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

SoftwareInventoryTest.php

19 KB
<?php

declare(strict_types=1);

/**
 * Software-Inventar – was zusammenfällt und was ersetzt wird.
 *
 * Zwei Dinge entscheiden über die Brauchbarkeit der Liste. Erstens die
 * Zusammenfassung: Dasselbe Programm heißt bei Agent und Intune leicht anders
 * und erscheint in 32- und 64-Bit-Fassung – fällt das nicht zusammen, steht
 * „auf wie vielen Geräten?" auf drei Zeilen verteilt. Zweitens das Ersetzen:
 * Jede Quelle meldet den vollständigen Stand eines Geräts, also muss ihr
 * Bestand ersetzt werden – aber nur ihrer, sonst trägt der Agent aus, was
 * Intune gemeldet hat.
 *
 * Dazu kommt, dass ohne eingeschaltete Erfassung nichts gespeichert werden darf.
 */

use OpenNIT\Core\Database;
use OpenNIT\Software\SoftwareInventory as Inventar;

return static function (): void {

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

    /** Ein Inventar auf einer gedachten Tabelle, das jede Anweisung festhält. */
    $inventarMit = static function (array $titel = []) {
        $db = new class ($titel) extends Database {
            /** @var array<string,int> normalized_key => id */
            public array $titel;
            /** @var list<array{0:string,1:array<int,mixed>}> */
            public array $anweisungen = [];

            public function __construct(array $titel)
            {
                $this->titel = $titel;
            }

            public function fetchColumn(string $sql, array $params = [], int $column = 0): mixed
            {
                if (str_contains($sql, 'FROM software_titles')) {
                    return $this->titel[(string)($params[0] ?? '')] ?? false;
                }
                return false;
            }

            public function execute(string $sql, array $params = []): \PDOStatement
            {
                $this->anweisungen[] = [$sql, $params];
                if (str_contains($sql, 'INSERT INTO software_titles')) {
                    // Schlüssel ist der dritte Platzhalter.
                    $this->titel[(string)$params[2]] = count($this->titel) + 1;
                }

                // Ein PDOStatement lässt sich nicht selbst erzeugen; der
                // Aufrufer liest `rowCount()`, also kommt ein echtes von einer
                // Datenbank im Arbeitsspeicher.
                return (new \PDO('sqlite::memory:'))->query('SELECT 1');
            }
        };

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

    /** Nur die Anweisungen einer Art. */
    $nur = static function (array $anweisungen, string $teil): array {
        return array_values(array_filter(
            $anweisungen,
            static fn (array $a): bool => str_contains($a[0], $teil)
        ));
    };

    // ----------------------------------------------------------------
    // Zusammenfassen
    // ----------------------------------------------------------------

    test('Groß- und Kleinschreibung fallen zusammen', function () {
        assertSame(
            Inventar::normalizeKey('Mozilla Firefox', 'Mozilla'),
            Inventar::normalizeKey('MOZILLA FIREFOX', 'mozilla')
        );
    });

    test('Die Architektur trennt dasselbe Programm nicht', function () {
        // Ein Rechner führt dieselbe Anwendung in beiden Fassungen; für die
        // Frage „auf wie vielen Geräten?" ist das eine Installation.
        $erwartet = Inventar::normalizeKey('7-Zip', 'Igor Pavlov');
        foreach (['7-Zip (x64)', '7-Zip (x86)', '7-Zip (64-bit)', '7-Zip - x64'] as $variante) {
            assertSame($erwartet, Inventar::normalizeKey($variante, 'Igor Pavlov'), 'Abweichung bei: ' . $variante);
        }
    });

    test('Schutzzeichen und Sonderzeichen fallen weg', function () {
        assertSame(
            Inventar::normalizeKey('Acrobat Reader', 'Adobe'),
            Inventar::normalizeKey('Acrobat® Reader™', 'Adobe')
        );
    });

    test('Die Version gehört nicht in den Schlüssel', function () {
        // Sonst wäre jede Version ein eigenes Programm und die Liste bestünde
        // aus Einzeleinträgen.
        assertTrue(
            Inventar::normalizeKey('Firefox', 'Mozilla') !== Inventar::normalizeKey('Firefox 120', 'Mozilla'),
            'Der Schlüssel unterscheidet ohnehin nicht'
        );
        assertFalse(str_contains(Inventar::normalizeKey('Firefox', 'Mozilla'), '120'));
    });

    test('Verschiedene Hersteller bleiben getrennt', function () {
        assertTrue(
            Inventar::normalizeKey('Reader', 'Adobe') !== Inventar::normalizeKey('Reader', 'Foxit'),
            'Gleichnamige Programme zweier Häuser fielen zusammen'
        );
    });

    test('Ohne Hersteller steht der Name für sich', function () {
        $ohne = Inventar::normalizeKey('Irgendwas', '');
        assertFalse(str_contains($ohne, '|'), 'Ein leerer Hersteller landete im Schlüssel: ' . $ohne);
        assertTrue($ohne !== '', 'Der Schlüssel ist leer');
    });

    test('Der Schlüssel passt in die Spalte', function () {
        // `normalized_key` fasst 255 Zeichen und trägt einen Unique-Index.
        $key = Inventar::normalizeKey(str_repeat('Programm ', 60), str_repeat('Hersteller ', 60));
        assertTrue(mb_strlen($key) <= 255, 'Der Schlüssel ist ' . mb_strlen($key) . ' Zeichen lang');
    });

    // ----------------------------------------------------------------
    // Systembestandteile
    // ----------------------------------------------------------------

    test('Updates und Laufzeitumgebungen gelten als Systembestandteil', function () {
        foreach ([
            'Update for Microsoft Excel 2016 (KB3115120) 32-Bit Edition',
            'Security Update for Microsoft Word',
            'Microsoft Visual C++ 2015-2022 Redistributable (x64)',
            'Microsoft .NET Framework 4.8',
            'Intel(R) Chipset Device Software',
            'German Language Pack',
        ] as $name) {
            assertTrue(Inventar::isSystemComponent($name), 'Nicht erkannt: ' . $name);
        }
    });

    test('Gewöhnliche Programme gelten nicht als Systembestandteil', function () {
        foreach ([
            'Mozilla Firefox',
            'Microsoft 365 Apps for enterprise',
            '7-Zip 23.01 (x64)',
            'Adobe Acrobat Reader DC',
            'Notepad++',
        ] as $name) {
            assertFalse(Inventar::isSystemComponent($name), 'Fälschlich eingestuft: ' . $name);
        }
    });

    // ----------------------------------------------------------------
    // Ersetzen
    // ----------------------------------------------------------------

    test('Die Meldung einer Quelle ersetzt nur deren eigenen Bestand', function () use ($inventarMit, $nur) {
        // Sonst trüge der Agent aus, was Intune gemeldet hat.
        [$inventar, $db] = $inventarMit();

        $inventar->replaceForAsset(7, 'agent', [['name' => 'Firefox', 'publisher' => 'Mozilla']]);

        $loeschen = $nur($db->anweisungen, 'DELETE FROM software_installations');
        assertSame(1, count($loeschen));
        assertTrue(str_contains($loeschen[0][0], 'source = ?'), 'Es wird quellenübergreifend gelöscht');
        assertSame(7, $loeschen[0][1][0]);
        assertSame('agent', $loeschen[0][1][1]);
    });

    test('Eine leere Meldung trägt den Bestand der Quelle aus', function () use ($inventarMit, $nur) {
        // „Nichts mehr installiert" ist eine Aussage, kein fehlender Wert.
        [$inventar, $db] = $inventarMit();

        $ergebnis = $inventar->replaceForAsset(7, 'agent', []);
        assertSame(0, $ergebnis['uebernommen']);

        $loeschen = $nur($db->anweisungen, 'DELETE FROM software_installations');
        assertSame(1, count($loeschen));
        assertFalse(str_contains($loeschen[0][0], 'NOT IN'), 'Bei leerer Meldung bleibt etwas stehen');
        assertSame([7, 'agent'], $loeschen[0][1]);
    });

    test('Dasselbe Programm zweimal gemeldet ist eine Installation', function () use ($inventarMit, $nur) {
        // 32- und 64-Bit-Eintrag desselben Programms auf einem Gerät.
        [$inventar, $db] = $inventarMit();

        $ergebnis = $inventar->replaceForAsset(7, 'agent', [
            ['name' => '7-Zip (x64)', 'publisher' => 'Igor Pavlov', 'version' => '23.01'],
            ['name' => '7-Zip (x86)', 'publisher' => 'Igor Pavlov', 'version' => '23.01'],
        ]);

        assertSame(1, $ergebnis['uebernommen']);
        assertSame(1, count($nur($db->anweisungen, 'INSERT INTO software_installations')));
    });

    test('Ein Eintrag ohne Namen wird übergangen', function () use ($inventarMit, $nur) {
        [$inventar, $db] = $inventarMit();

        $ergebnis = $inventar->replaceForAsset(7, 'agent', [
            ['name' => '',    'publisher' => 'X'],
            ['name' => '   ', 'publisher' => 'X'],
            ['name' => 'Echt', 'publisher' => 'X'],
        ]);

        assertSame(1, $ergebnis['uebernommen']);
        assertSame(1, count($nur($db->anweisungen, 'INSERT INTO software_installations')));
    });

    test('Eine unbekannte Quelle schreibt nichts', function () use ($inventarMit) {
        // Die Spalte kennt nur zwei Werte; alles andere liefe in einen Fehler.
        [$inventar, $db] = $inventarMit();

        assertSame(['uebernommen' => 0, 'entfernt' => 0], $inventar->replaceForAsset(7, 'relution', [['name' => 'X']]));
        assertSame(['uebernommen' => 0, 'entfernt' => 0], $inventar->replaceForAsset(0, 'agent', [['name' => 'X']]));
        assertSame([], $db->anweisungen);
    });

    test('Die Zahl der Programme je Gerät ist begrenzt', function () use ($inventarMit, $nur) {
        // Eine fehlerhafte oder mutwillige Meldung soll die Tabelle nicht füllen.
        [$inventar, $db] = $inventarMit();

        $viele = [];
        for ($i = 0; $i < Inventar::MAX_JE_GERAET + 200; $i++) {
            $viele[] = ['name' => 'Programm ' . $i, 'publisher' => 'Test'];
        }
        $ergebnis = $inventar->replaceForAsset(7, 'agent', $viele);

        assertSame(Inventar::MAX_JE_GERAET, $ergebnis['uebernommen']);
        assertSame(Inventar::MAX_JE_GERAET, count($nur($db->anweisungen, 'INSERT INTO software_installations')));
    });

    test('Eine erneute Meldung legt kein zweites Mal an', function () use ($inventarMit, $nur) {
        [$inventar, $db] = $inventarMit();

        $inventar->replaceForAsset(7, 'agent', [['name' => 'Firefox', 'publisher' => 'Mozilla']]);
        $inventar->replaceForAsset(8, 'agent', [['name' => 'Firefox', 'publisher' => 'Mozilla']]);

        // Der Titel entsteht einmal, die Installation je Gerät.
        assertSame(1, count($nur($db->anweisungen, 'INSERT INTO software_titles')));
        assertSame(2, count($nur($db->anweisungen, 'INSERT INTO software_installations')));
    });

    test('Ein zweiter Lauf schreibt die Installation fort statt neu', function () use ($inventarMit, $nur) {
        [$inventar, $db] = $inventarMit();

        $inventar->replaceForAsset(7, 'agent', [['name' => 'Firefox', 'publisher' => 'Mozilla', 'version' => '120']]);
        $einfuegen = $nur($db->anweisungen, 'INSERT INTO software_installations')[0][0];

        assertTrue(str_contains($einfuegen, 'ON DUPLICATE KEY UPDATE'), 'Der zweite Lauf liefe in den Schlüssel');
        assertTrue(str_contains($einfuegen, 'version      = VALUES(version)'), 'Die Version wird nicht fortgeschrieben');
        assertTrue(str_contains($einfuegen, 'last_seen_at = VALUES(last_seen_at)'), 'Der Zeitpunkt wird nicht fortgeschrieben');
        assertFalse(str_contains($einfuegen, 'first_seen_at = VALUES'), 'Der erste Fund wird überschrieben');
    });

    // ----------------------------------------------------------------
    // Datumsangaben
    // ----------------------------------------------------------------

    test('Das Windows-Datumsformat wird gelesen', function () use ($inventarMit, $nur) {
        // Die Registry meldet das Installationsdatum als yyyyMMdd.
        [$inventar, $db] = $inventarMit();

        $inventar->replaceForAsset(7, 'agent', [
            ['name' => 'A', 'install_date' => '20260315'],
        ]);

        $werte = $nur($db->anweisungen, 'INSERT INTO software_installations')[0][1];
        assertSame('2026-03-15', $werte[3]);
    });

    test('Ein unbrauchbares Datum wird zu nichts, nicht zu 1970', function () use ($inventarMit, $nur) {
        [$inventar, $db] = $inventarMit();

        foreach (['', 'keine Ahnung', '0', '00000000'] as $eingabe) {
            $db->anweisungen = [];
            $inventar->replaceForAsset(7, 'agent', [['name' => 'A', 'install_date' => $eingabe]]);
            $werte = $nur($db->anweisungen, 'INSERT INTO software_installations')[0][1];
            assertSame(null, $werte[3], 'Angenommen: ' . var_export($eingabe, true));
        }
    });

    // ----------------------------------------------------------------
    // Nichts ohne Einschalten
    // ----------------------------------------------------------------

    test('Ohne Modul und Schalter nimmt der Endpunkt nichts an', function () use ($quelle) {
        $controller = $quelle('src/Controllers/AssetSyncController.php');

        $von       = (int)strpos($controller, 'private function uebernehmeSoftware(');
        assertTrue($von > 0, 'Die Übernahme fehlt');
        $abschnitt = substr($controller, $von, 900);

        assertTrue(str_contains($abschnitt, "modules']['software']"), 'Das Modul wird nicht geprüft');
        assertTrue(str_contains($abschnitt, 'agentEnabled('), 'Der Schalter wird nicht geprüft');
        assertTrue(str_contains($abschnitt, 'replaceForAsset('), 'Es wird nichts übernommen');
    });

    test('Der Agent sammelt nur mit eingeschalteter Erfassung', function () use ($quelle) {
        $admin = $quelle('src/Controllers/AdminAssetSyncController.php');

        // Der Schalter im Skript kommt aus der Einstellung, nicht fest verdrahtet.
        assertTrue(str_contains($admin, '$COLLECT_SOFTWARE_PLACEHOLDER'), 'Der Platzhalter fehlt');
        assertTrue(
            (bool)preg_match("/str_replace\('\\\$COLLECT_SOFTWARE_PLACEHOLDER',\s*\\\$collectSoftware \? '\\\$true' : '\\\$false'/", $admin),
            'Der Platzhalter wird nicht ersetzt'
        );
        assertTrue(str_contains($admin, 'agentEnabled('), 'Die Einstellung wirkt nicht auf das Skript');

        // Ohne Sammeln darf das Feld gar nicht erst mitgeschickt werden: Eine
        // leere Liste hieße „nichts installiert" und löschte den Bestand.
        assertTrue(str_contains($admin, "if (\$WithSoftware) {"), 'Das Feld geht auch ohne Erfassung hinaus');
    });

    test('Der Intune-Abgleich liest nur mit eingeschalteter Erfassung', function () use ($quelle) {
        foreach (['src/Cron/Handlers/IntuneSyncHandler.php', 'src/Controllers/Admin/IntuneAdminController.php'] as $datei) {
            $inhalt = $quelle($datei);
            assertTrue(str_contains($inhalt, "modules']['software']"), 'Modul ungeprüft in ' . $datei);
            assertTrue(str_contains($inhalt, 'intuneEnabled('), 'Schalter ungeprüft in ' . $datei);
        }

        $service = $quelle('src/Services/IntuneSyncService.php');
        assertTrue(
            (bool)preg_match('/if \(\$this->syncSoftware && \$assetId > 0\)/', $service),
            'Der Abruf hängt an keinem Schalter'
        );
    });

    test('Das Modul ist im Auslieferungszustand aus', function () use ($quelle) {
        $index = $quelle('public/index.php');
        assertTrue(
            (bool)preg_match("/'software'\s*=> false/", $index),
            'Das Software-Inventar ist von sich aus eingeschaltet'
        );
        // Ohne Eintrag in der Speicherschleife ließe es sich nie einschalten.
        assertTrue(str_contains($quelle('src/Controllers/Admin/SettingsController.php'), "'software'"),
            'Der Schalter wird nicht gespeichert');
    });

    test('Die Seiten sind nur mit Recht und bei aktivem Modul erreichbar', function () use ($quelle) {
        $app = $quelle('src/Core/Application.php');

        $von = (int)strpos($app, "\$this->config['modules']['software'] ?? false");
        assertTrue($von > 0, 'Die Routen stehen nicht im Modul-Block');
        // Fenster großzügig: Der Block wächst mit jeder neuen Route.
        $block = substr($app, $von, 2200);

        foreach (['SoftwareController@index', 'SoftwareController@show'] as $aktion) {
            assertTrue(str_contains($block, $aktion . "',        \$lesen")
                    || str_contains($block, $aktion . "',         \$lesen"),
                'Die Aktion ' . $aktion . ' prüft das Leserecht nicht');
        }
        foreach (['SoftwareController@toggleSystem', 'SoftwareController@cpeAiSuggest',
                  'SoftwareController@cpeAiApply', 'SoftwareAdminController@settings',
                  'SoftwareAdminController@saveSettings', 'SoftwareAdminController@purge'] as $aktion) {
            assertTrue((bool)preg_match('/' . preg_quote($aktion, '/') . "',\s*\\\$pflege/", $block),
                'Die Aktion ' . $aktion . ' prüft das Pflegerecht nicht');
        }

        assertTrue(str_contains($block, "'capability' => 'view_software'"), 'Das Leserecht fehlt');
        assertTrue(str_contains($block, "'capability' => 'manage_software'"), 'Das Pflegerecht fehlt');
    });

    // ----------------------------------------------------------------
    // Schema
    // ----------------------------------------------------------------

    test('Das Schema trägt Programme und Installationen getrennt', 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, 'software_installations')) {
                $migration = $inhalt;
                break;
            }
        }

        assertTrue($migration !== null, 'Keine Migration legt die Tabellen an');
        assertTrue(str_contains($migration, 'CREATE TABLE IF NOT EXISTS `software_titles`'), 'Die Programme fehlen');
        assertTrue(str_contains($migration, 'CREATE TABLE IF NOT EXISTS `software_installations`'), 'Die Installationen fehlen');

        // Die Quelle gehört in den Schlüssel: Agent und Intune melden dasselbe
        // Gerät unabhängig voneinander.
        assertTrue(
            str_contains($migration, 'UNIQUE KEY `uq_software_install` (`asset_id`, `title_id`, `source`)'),
            'Agent und Intune überschreiben sich gegenseitig'
        );
        // Verschwindet ein Gerät, haben seine Installationen keine Aussage mehr.
        assertTrue(str_contains($migration, 'fk_software_install_asset'), 'Die Installationen hängen nicht am Gerät');
        assertTrue(str_contains($migration, 'INSERT IGNORE INTO `capabilities`'), 'Die Rechte sind nicht wiederholbar angelegt');
        assertTrue(str_contains($migration, 'view_software'), 'Das Leserecht fehlt');
    });

    test('Jedes Formular des Moduls trägt den Token', function () use ($quelle) {
        foreach (['templates/admin/software/settings.php', 'templates/software/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');
        }
    });
};