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