OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

CpeAiMatcherTest.php

16 KB
<?php

declare(strict_types=1);

use OpenNIT\Software\CpeAiMatcher;

/**
 * Die KI-Zuordnung der CPE-Kennungen.
 *
 * Entscheidend ist, was die Antwort der KI *nicht* darf: eine Kennung in
 * freier Form, ein Programm, das nicht gefragt war, oder eine Zuordnung, die
 * ohne Bestätigung gespeichert wird. Eine falsche Kennung meldete Geräte als
 * betroffen, die es nicht sind – oder verschwiege betroffene.
 */
return static function (): void {

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

    // ----------------------------------------------------------------
    // Die Antwort der KI
    // ----------------------------------------------------------------

    test('Das vereinbarte Format wird gelesen', function () {
        $gelesen = CpeAiMatcher::leseZuordnungen('{"zuordnungen":[{"id":7,"cpe":"adobe:photoshop","sicher":true,"grund":"eindeutig"}]}');

        assertSame(1, count($gelesen));
        assertSame(7, $gelesen[0]['id']);
        assertSame('adobe:photoshop', $gelesen[0]['cpe']);
    });

    test('Ein Code-Block um die Antwort stört nicht', function () {
        // Modelle verpacken JSON gern in ```json … ```.
        $gelesen = CpeAiMatcher::leseZuordnungen("```json\n{\"zuordnungen\":[{\"id\":3,\"cpe\":\"microsoft:edge\"}]}\n```");

        assertSame(1, count($gelesen));
        assertSame('microsoft:edge', $gelesen[0]['cpe']);
    });

    test('Eine nackte Liste wird ebenso angenommen', function () {
        $gelesen = CpeAiMatcher::leseZuordnungen('[{"id":1,"cpe":"nginx:nginx"},{"ohne_id":true}]');

        assertSame(1, count($gelesen), 'Ein Eintrag ohne id wurde übernommen');
        assertSame(1, $gelesen[0]['id']);
    });

    test('Eine unlesbare Antwort ergibt keine Zuordnung', function () {
        // Lieber kein Vorschlag als eine Fehlerseite.
        foreach (['', 'Tut mir leid, ich kann das nicht.', '{kaputt', 'null', '"text"'] as $antwort) {
            assertSame([], CpeAiMatcher::leseZuordnungen($antwort), 'Angenommen: ' . var_export($antwort, true));
        }
    });

    // ----------------------------------------------------------------
    // Verfügbarkeit
    // ----------------------------------------------------------------

    test('Ohne eingerichteten Anbieter ist die Zuordnung nicht verfügbar', function () {
        assertFalse(CpeAiMatcher::verfuegbar([]));
        assertFalse(CpeAiMatcher::verfuegbar(['provider' => 'anthropic']));
        assertFalse(CpeAiMatcher::verfuegbar(['provider' => 'openai', 'anthropic_api_key' => 'enc:v1:x']));
        assertFalse(CpeAiMatcher::verfuegbar(['provider' => 'openai_compatible', 'custom_base_url' => '   ']));
    });

    test('Mit Anbieter und Zugang ist sie verfügbar', function () {
        assertTrue(CpeAiMatcher::verfuegbar(['provider' => 'anthropic', 'anthropic_api_key' => 'enc:v1:x']));
        assertTrue(CpeAiMatcher::verfuegbar(['provider' => 'deepseek', 'deepseek_api_key' => 'enc:v1:x']));
        assertTrue(CpeAiMatcher::verfuegbar(['provider' => 'openai_compatible', 'custom_base_url' => 'http://127.0.0.1:11434/v1']));
    });

    // ----------------------------------------------------------------
    // Der Weg durch den Controller
    // ----------------------------------------------------------------

    test('Die Kennung kommt aus der Sitzung, nicht aus dem Formular', function () use ($quelle) {
        // Sonst ließe sich über ein verändertes Formular eine beliebige
        // Kennung zuordnen – samt der Geräte, die sie betrifft.
        $controller = $quelle('src/Controllers/SoftwareController.php');

        $von       = (int)strpos($controller, 'public function cpeAiApply(');
        assertTrue($von > 0, 'Das Übernehmen fehlt');
        $abschnitt = substr($controller, $von, 2200);

        assertTrue(str_contains($abschnitt, "get(self::CPE_AI_SITZUNG)"), 'Die Vorschläge werden nicht aus der Sitzung gelesen');
        assertFalse(str_contains($abschnitt, "post('cpe'"), 'Eine Kennung aus dem Formular wird übernommen');
        assertTrue(str_contains($abschnitt, 'linkCpe('), 'Es wird nichts zugeordnet');
        assertTrue(str_contains($abschnitt, 'remove(self::CPE_AI_SITZUNG)'), 'Die Vorschläge bleiben nach dem Übernehmen liegen');
    });

    test('Der Lauf ordnet nur das Zweifelsfreie selbst zu', function () use ($quelle) {
        // Was die KI als sicher bezeichnet, gilt sofort; alles andere wartet
        // auf eine Entscheidung und darf hier nicht geschrieben werden.
        $controller = $quelle('src/Controllers/SoftwareController.php');

        $von       = (int)strpos($controller, 'public function cpeAiSuggest(');
        $bis       = (int)strpos($controller, 'public function cpeAiApply(');
        assertTrue($von > 0 && $bis > $von, 'Der Lauf fehlt');
        $abschnitt = substr($controller, $von, $bis - $von);

        assertTrue(str_contains($abschnitt, 'if (empty($vorschlag[\'sicher\'])) {'), 'Es wird nicht nach der Einschätzung getrennt');
        assertTrue(str_contains($abschnitt, '$offeneWahl[] = $vorschlag;'), 'Das Unsichere wird nicht zurückgestellt');
        assertTrue(str_contains($abschnitt, '\'je_id\'    => array_column($offeneWahl, \'cpe\', \'id\')'),
            'In der Sitzung liegt mehr als das Unsichere');
        assertTrue(str_contains($abschnitt, 'software_cpe_linked'), 'Das Zweifelsfreie wird nicht protokolliert');
        assertTrue(str_contains($abschnitt, 'CpeAiMatcher::verfuegbar'), 'Es wird ohne eingerichtete KI losgelaufen');
        assertTrue(str_contains($abschnitt, 'aiBudgetErschoepft'), 'Die Monatsgrenze der KI-Kosten wird nicht geprüft');
        assertTrue(str_contains($abschnitt, 'logAiUsage'), 'Der Verbrauch wird nicht mitgeschrieben');
        assertTrue(str_contains($abschnitt, 'software_cpe_ai_suggested'), 'Der Lauf steht nicht im Audit-Log');
    });

    test('Beides ist nur mit Pflegerecht erreichbar und läuft vor der {id}-Route', function () use ($quelle) {
        $app = $quelle('src/Core/Application.php');

        foreach (['cpeAiSuggest', 'cpeAiApply'] as $aktion) {
            assertTrue(
                (bool)preg_match('/SoftwareController@' . $aktion . "',\s*\\\$pflege/", $app),
                'Die Aktion ' . $aktion . ' prüft das Pflegerecht nicht'
            );
        }

        $knopf = (int)strpos($app, "/software/cpe-ai'");
        $id    = (int)strpos($app, "/software/{id}'");
        assertTrue($knopf > 0 && $id > 0 && $knopf < $id, 'Der Router fängt /software/cpe-ai als {id} ab');
    });

    test('Nur Name und Hersteller gehen an die KI', function () use ($quelle) {
        // Geräte, Versionen und Standorte haben in einer Anfrage nach der
        // Produktkennung nichts zu suchen.
        $matcher = $quelle('src/Software/CpeAiMatcher.php');

        $von       = (int)strpos($matcher, 'public function zuordnen(');
        $bis       = (int)strpos($matcher, 'public static function leseZuordnungen(');
        $abschnitt = substr($matcher, $von, $bis - $von);

        assertTrue(str_contains($abschnitt, "\$t['name']"), 'Der Programmname fehlt');
        assertTrue(str_contains($abschnitt, "\$t['publisher']"), 'Der Hersteller fehlt');
        foreach (['geraete', 'asset', 'version', 'location', 'standort', 'inventory_number'] as $feld) {
            assertFalse(str_contains($abschnitt, $feld), 'Es wird „' . $feld . '" mitgeschickt');
        }
    });

    test('Die Seite trennt Übernommenes von dem, was zur Entscheidung steht', function () use ($wurzel) {
        // Gerendert mit einer schlanken Vorlagen-Hülle: So fällt eine fehlende
        // Angabe oder ein falscher Schlüssel hier auf und nicht erst, wenn
        // jemand den Knopf drückt.
        $huelle = new class {
            /** @var array<string,mixed> */
            public array $layout = [];

            public function layout(string $name, array $daten = []): void
            {
                $this->layout = [$name, $daten];
            }

            public function t(string $text, array $platzhalter = [], string $kontext = ''): string
            {
                $suche = $ersatz = [];
                foreach ($platzhalter as $name => $wert) {
                    $suche[]  = '{' . $name . '}';
                    $ersatz[] = (string)$wert;
                }
                return str_replace($suche, $ersatz, $text);
            }

            public function te(string $text, array $platzhalter = [], string $kontext = ''): string
            {
                return htmlspecialchars($this->t($text, $platzhalter), ENT_QUOTES | ENT_HTML5, 'UTF-8', false);
            }

            public function e(mixed $wert): string
            {
                return htmlspecialchars(is_scalar($wert) || $wert === null ? (string)$wert : '', ENT_QUOTES | ENT_HTML5, 'UTF-8');
            }

            public function csrf(): string
            {
                return 'pruef-token';
            }

            /** @param array<string,mixed> $daten */
            public function zeichne(string $datei, array $daten): string
            {
                extract($daten);
                ob_start();
                include $datei;
                return (string)ob_get_clean();
            }
        };

        // Jede Warnung (etwa ein fehlender Schlüssel) gilt als Fehlschlag.
        set_error_handler(static function (int $art, string $text, string $datei = '', int $zeile = 0): bool {
            throw new ErrorException($text, 0, $art, $datei, $zeile);
        });

        try {
            $html = $huelle->zeichne($wurzel . '/templates/software/cpe_ai.php', [
                'page_title'    => 'Kennungen per KI zuordnen',
                'current_route' => 'software',
                'applied'       => [
                    ['id' => 7,  'cpe' => 'adobe:photoshop', 'sicher' => true, 'grund' => 'Produktname eindeutig'],
                ],
                'suggestions'   => [
                    ['id' => 11, 'cpe' => 'hp:support_assistant', 'sicher' => false, 'grund' => 'Store-Paket, Zuordnung vermutet'],
                ],
                'titles'        => [
                    7  => ['id' => 7,  'name' => 'Adobe Photoshop 2026', 'publisher' => 'Adobe Inc.', 'geraete' => 3, 'is_system' => 0],
                    11 => ['id' => 11, 'name' => 'AD2F1837.HPSupportAssistant', 'publisher' => '', 'geraete' => 1, 'is_system' => 0],
                    12 => ['id' => 12, 'name' => 'Hausinterne Anwendung', 'publisher' => '', 'geraete' => 2, 'is_system' => 1],
                ],
                'reports'       => ['adobe:photoshop' => 4, 'hp:support_assistant' => 0],
                'asked'         => 3,
                'open_total'    => 12,
                'model'         => 'claude-haiku-4-5-20251001',
            ]);
        } finally {
            restore_error_handler();
        }

        assertTrue(str_contains($html, 'action="/software/cpe-ai/apply"'), 'Das Formular zeigt nicht auf das Übernehmen');
        assertTrue(str_contains($html, 'name="_csrf" value="pruef-token"'), 'Der CSRF-Token fehlt');
        assertTrue(str_contains($html, '<code>adobe:photoshop</code>'), 'Die vorgeschlagene Kennung fehlt');
        assertTrue(str_contains($html, 'Direkt übernommen'), 'Das Zweifelsfreie wird nicht als übernommen ausgewiesen');
        assertFalse(str_contains($html, 'name="ids[]" value="7"'), 'Das bereits Zugeordnete steht nochmals zur Wahl');
        assertTrue(str_contains($html, 'name="ids[]" value="11"'), 'Das Unsichere steht nicht zur Wahl');
        assertTrue(str_contains($html, 'Hausinterne Anwendung'), 'Das Programm ohne Vorschlag fehlt');
        assertTrue(str_contains($html, 'Produktname eindeutig'), 'Die Begründung fehlt');
        assertTrue(str_contains($html, 'claude-haiku-4-5-20251001'), 'Das Modell wird nicht genannt');
        assertSame('layouts/main', $huelle->layout[0] ?? '', 'Die Seite läuft nicht im Haupt-Layout');
    });

    test('Ein Lauf, der wartet, zeigt das auch', function () use ($quelle) {
        // Ohne Rückmeldung wirkt der Klick, als sei nichts passiert – und ein
        // zweiter Klick bricht die laufende Anfrage ab, womit die Antwort nie
        // ankommt.
        $js  = $quelle('public/assets/js/app.js');
        $css = $quelle('public/assets/css/app.css');

        assertTrue(str_contains($js, 'OpenNIT.initBusyForms'), 'Die Sperre fehlt');
        assertTrue(str_contains($js, 'this.initBusyForms();'), 'Die Sperre wird nicht eingehängt');
        assertTrue(str_contains($css, '.spinner {'), 'Der Wartezeiger fehlt in app.css');

        $von       = (int)strpos($js, 'OpenNIT.initBusyForms');
        $abschnitt = substr($js, $von, 1200);
        assertTrue(str_contains($abschnitt, 'setTimeout('),
            'Die Schaltflächen werden sofort gesperrt – ein benannter Absender ginge dann nicht mit');

        foreach (['templates/software/index.php', 'templates/software/cpe_ai.php'] as $vorlage) {
            assertTrue(str_contains($quelle($vorlage), 'data-busy="'), $vorlage . ' sagt beim Warten nichts');
        }
    });

    test('Jeder Lauf nimmt die nächsten Programme, nicht immer dieselben', function () use ($quelle, $wurzel) {
        // Ohne Gedächtnis stünden Programme, zu denen kein Vorschlag zustande
        // kommt, bei jedem Lauf erneut vorn – der übrige Bestand käme nie dran.
        $match = $quelle('src/Software/CveMatch.php');

        $von       = (int)strpos($match, 'public function titlesWithoutCpe(');
        assertTrue($von > 0, 'Die Auswahl fehlt');
        $abschnitt = substr($match, $von, 900);

        assertTrue(str_contains($abschnitt, '(t.cpe_checked_at IS NULL) DESC'), 'Noch nie Betrachtetes kommt nicht zuerst');
        assertTrue(str_contains($abschnitt, 't.cpe_checked_at ASC'), 'Die Reihe läuft nicht weiter');
        assertTrue(str_contains($match, 'public function markCpeChecked('), 'Es wird nichts vermerkt');

        $controller = $quelle('src/Controllers/SoftwareController.php');
        assertTrue(str_contains($controller, 'markCpeChecked(array_keys($jeId))'),
            'Der Lauf vermerkt die betrachteten Programme nicht');

        $migration = '';
        foreach ((array)glob($wurzel . '/database/migrations/*.sql') as $datei) {
            $inhalt = (string)file_get_contents((string)$datei);
            if (str_contains($inhalt, 'cpe_checked_at')) {
                $migration = $inhalt;
                break;
            }
        }
        assertTrue($migration !== '', 'Keine Migration legt die Spalte an');
        assertTrue(str_contains($migration, 'ADD COLUMN `cpe_checked_at`'), 'Die Spalte wird nicht ergänzt');
    });

    test('Ohne jede Zuordnung bleibt die Seite ohne doppelte Meldung', function () use ($quelle) {
        // Eine leere Meldung direkt über der Karte, die dasselbe noch einmal
        // sagt, klebte an ihr und sagte nichts Neues: Die Karte „Ohne
        // Vorschlag" führt die Programme bereits auf.
        $vorlage = $quelle('templates/software/cpe_ai.php');

        assertTrue(str_contains($vorlage, 'if ($applied === [] && $suggestions === [] && $ohne === []):'),
            'Die leere Meldung erscheint auch neben der Liste');
        assertTrue(str_contains($vorlage, 'Diesmal kam keine Zuordnung zustande.'), 'Der Lauf sagt sein Ergebnis nicht');
        assertTrue(str_contains($vorlage, 'if ($suggestions === []):'), 'Ohne Formular fehlt der Weg zurück');
        assertFalse(str_contains($vorlage, 'style="margin-bottom:1rem;"'), 'Die Karten überschreiben den Haus-Abstand');
    });

    test('Die Vorlage legt nur bestätigte Zuordnungen vor', function () use ($quelle) {
        $vorlage = $quelle('templates/software/cpe_ai.php');

        assertTrue(str_contains($vorlage, 'name="ids[]"'), 'Es gibt keine Auswahl je Zeile');
        assertFalse(str_contains($vorlage, 'name="cpe"'), 'Die Kennung steht im Formular');
        assertTrue(str_contains($vorlage, 'name="_csrf"'), 'Das Formular hat kein CSRF-Feld');
        assertTrue(str_contains($vorlage, 'form-control') || !str_contains($vorlage, 'form-input'), 'Erfundene Eingabeklasse');
    });
};