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