Quelltext des aktuellen Standes
<?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');
});
};