Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Assets\AssetHistory;
use OpenNIT\Controllers\AssetController;
use OpenNIT\Core\Database;
use OpenNIT\Services\MdmAssetChanges;
/**
* Deckt den MDM-Abgleich am Gerät ab: dass seine Änderungen im Lebenslauf
* auftauchen, dass ein Lauf ohne Unterschied nichts protokolliert und dass die
* vom MDM geführte Zuweisung in OpenNIT nicht bearbeitbar ist.
*/
return static function (): void {
$wurzel = dirname(__DIR__);
/** Datenbank-Doppel, das Namen zu Kennungen liefert. */
$db = static function (array $namen = []): Database {
return new class ($namen) extends Database {
public function __construct(private array $namen)
{
}
public function fetchOne(string $sql, array $params = []): ?array
{
$id = (int)($params[0] ?? 0);
return isset($this->namen[$id]) ? ['wert' => $this->namen[$id]] : null;
}
};
};
/** Audit-Logger-Doppel, das die Aufrufe sammelt. */
$logger = static function (): object {
return new class extends \OpenNIT\Audit\AuditLogger {
/** @var list<array<string,mixed>> */
public array $eintraege = [];
public function __construct()
{
}
public function log(
string $actionCategory,
string $actionType,
?string $objectType = null,
?int $objectId = null,
?string $objectName = null,
?array $details = null,
string $result = 'success',
string $riskLevel = 'low',
?array $oldValue = null,
?array $newValue = null
): int {
$this->eintraege[] = [
'action' => $actionType,
'object' => $objectType,
'id' => $objectId,
'details' => $details,
'alt' => $oldValue,
'neu' => $newValue,
];
return count($this->eintraege);
}
};
};
// ----------------------------------------------------------------
// Protokollierung der Abgleich-Änderungen
// ----------------------------------------------------------------
test('Ein Abgleich ohne Unterschied schreibt nichts', function () use ($db, $logger) {
// Sonst stünde nach jedem stündlichen Lauf ein leerer Eintrag am Gerät.
$audit = $logger();
$zeile = ['inventory_number' => 'IT-1', 'name' => 'Laptop', 'status' => 'active', 'assigned_person_id' => 5];
$geschrieben = MdmAssetChanges::schreibe($db(), $audit, 'intune', 42, $zeile, $zeile);
assertFalse($geschrieben);
assertSame(0, count($audit->eintraege));
});
test('Eine Änderung landet als Eintrag am Gerät', function () use ($db, $logger) {
$audit = $logger();
$geschrieben = MdmAssetChanges::schreibe(
$db(),
$audit,
'intune',
42,
['inventory_number' => 'IT-1', 'name' => 'Laptop', 'status' => 'active'],
['inventory_number' => 'IT-1', 'name' => 'Laptop', 'status' => 'decommissioned']
);
assertTrue($geschrieben);
assertSame(1, count($audit->eintraege));
$eintrag = $audit->eintraege[0];
assertSame('asset_mdm_synced', $eintrag['action']);
assertSame('asset', $eintrag['object']);
assertSame(42, $eintrag['id']);
assertSame('intune', $eintrag['details']['source']);
// Status in der Sprache der Oberfläche, nicht als Datenbankwert.
assertSame(['Status' => 'Aktiv'], $eintrag['alt']);
assertSame(['Status' => 'Ausgemustert'], $eintrag['neu']);
});
test('Kennungen werden zu Namen aufgelöst', function () use ($db, $logger) {
// „Zugewiesen an: 5 → 9" wäre für niemanden lesbar.
$audit = $logger();
MdmAssetChanges::schreibe(
$db([5 => 'Anna Beispiel', 9 => 'Bea Muster']),
$audit,
'relution',
42,
['assigned_person_id' => 5],
['assigned_person_id' => 9]
);
assertSame(['Zugewiesen an' => 'Anna Beispiel'], $audit->eintraege[0]['alt']);
assertSame(['Zugewiesen an' => 'Bea Muster'], $audit->eintraege[0]['neu']);
});
test('Eine unbekannte Kennung bricht den Abgleich nicht', function () use ($db, $logger) {
$audit = $logger();
MdmAssetChanges::schreibe($db(), $audit, 'intune', 42, ['type_id' => 3], ['type_id' => 4]);
assertSame(['Typ' => '#3'], $audit->eintraege[0]['alt']);
assertSame(['Typ' => '#4'], $audit->eintraege[0]['neu']);
});
test('Leere Werte gelten als unverändert', function () use ($db, $logger) {
// NULL, '' und 0 beschreiben denselben Zustand; ein Wechsel zwischen
// ihnen ist keine Änderung, die jemanden interessiert.
$audit = $logger();
$geschrieben = MdmAssetChanges::schreibe(
$db(),
$audit,
'intune',
42,
['serial_number' => null, 'assigned_person_id' => 0],
['serial_number' => '', 'assigned_person_id' => null]
);
assertFalse($geschrieben);
});
test('Ein Feld, das nur eine Seite geladen hat, zählt nicht als Änderung', function () use ($db, $logger) {
// Nicht jeder Abgleich liest denselben Spaltensatz vorher wie nachher.
// Eine Lücke im Vergleich als Änderung zu werten ergäbe bei jedem Lauf
// denselben Eintrag – genau das Rauschen, das hier vermieden wird.
$audit = $logger();
$geschrieben = MdmAssetChanges::schreibe(
$db(),
$audit,
'relution',
42,
['name' => 'Laptop'],
['name' => 'Laptop', 'notes' => 'Ersatzakku bestellt']
);
assertFalse($geschrieben);
assertSame(0, count($audit->eintraege));
});
test('Der Vorzustand jedes Abgleichs deckt die verglichenen Spalten ab', function () use ($wurzel) {
// Was der Vergleich auswertet, muss auf beiden Seiten geladen sein –
// sonst bliebe eine echte Änderung stumm.
$spalten = ['name', 'serial_number', 'status', 'manufacturer_id', 'type_id', 'assigned_person_id'];
foreach (['IntuneSyncService', 'RelutionSyncService'] as $dienst) {
$src = (string)file_get_contents($wurzel . "/src/Services/{$dienst}.php");
$von = strpos($src, 'protokolliereAenderung(int $assetId');
$nachSelect = substr($src, (int)$von, 600);
foreach ($spalten as $spalte) {
// Im Nachzustand …
assertTrue(str_contains($nachSelect, $spalte), "{$dienst}: {$spalte} fehlt im Nachzustand");
}
}
// … und im Vorzustand, den die Suchabfragen laden.
$relution = (string)file_get_contents($wurzel . '/src/Services/RelutionSyncService.php');
foreach ($spalten as $spalte) {
assertTrue(
str_contains($relution, "SELECT id, status, mdm_source, hardware_specs, inventory_number,\n name, serial_number, manufacturer_id, type_id, assigned_person_id"),
"Relution: Vorzustand unvollständig ({$spalte})"
);
}
$intune = (string)file_get_contents($wurzel . '/src/Services/IntuneSyncService.php');
$von = strpos($intune, '$cols = ');
$cols = substr($intune, (int)$von, 220);
foreach ($spalten as $spalte) {
assertTrue(str_contains($cols, $spalte), "Intune: {$spalte} fehlt im Vorzustand");
}
});
test('Der Eintrag erscheint im Lebenslauf mit deutscher Bezeichnung', function () {
$eintrag = AssetHistory::build([[
'event_timestamp' => '2026-03-01 06:00:00',
'action_type' => MdmAssetChanges::AKTION,
'user_name' => null,
'old_value_json' => json_encode(['Status' => 'Aktiv']),
'new_value_json' => json_encode(['Status' => 'Lager']),
'details_json' => json_encode(['message' => 'Abgleich mit Intune', 'source' => 'intune']),
]])[0];
assertSame('Automatisch abgeglichen', $eintrag['title']);
assertSame('Abgleich mit Intune', $eintrag['detail']);
assertSame(null, $eintrag['actor'], 'Ein Abgleich hat keine handelnde Person');
assertSame([['field' => 'Status', 'from' => 'Aktiv', 'to' => 'Lager']], $eintrag['changes']);
});
// ----------------------------------------------------------------
// Verdrahtung in den Abgleich-Diensten
// ----------------------------------------------------------------
test('Beide Abgleiche protokollieren am Gerät', function () use ($wurzel) {
foreach (['IntuneSyncService', 'RelutionSyncService'] as $dienst) {
$src = (string)file_get_contents($wurzel . "/src/Services/{$dienst}.php");
assertTrue(str_contains($src, 'protokolliereAenderung('), "Kein Protokoll in {$dienst}");
assertTrue(str_contains($src, 'MdmAssetChanges::schreibe'), "Kein Aufruf in {$dienst}");
// Der Probelauf darf nichts schreiben – die Protokollierung liegt
// im selben Zweig wie das UPDATE.
assertTrue(str_contains($src, 'private readonly ?\\OpenNIT\\Audit\\AuditLogger $audit = null'), "Kein Logger in {$dienst}");
}
});
test('Jede Stelle, die einen Abgleich startet, reicht den Logger durch', function () use ($wurzel) {
// Der Relution-Abgleich läuft seit der Umstellung auf mehrere Instanzen
// über den Runner; dort entsteht der Dienst, dort gehört der Logger hin.
$stellen = [
'/src/Controllers/Admin/IntuneAdminController.php',
'/src/Controllers/Admin/RelutionAdminController.php',
'/src/Cron/Handlers/IntuneSyncHandler.php',
'/src/Services/RelutionSyncRunner.php',
];
foreach ($stellen as $datei) {
$src = (string)file_get_contents($wurzel . $datei);
assertTrue((bool)preg_match('/audit:\s*\S/', $src), "Kein Logger übergeben: {$datei}");
}
// Und es gibt keine Stelle mehr, die den Dienst ohne Logger baut.
foreach (['/src/Cron/Handlers/RelutionSyncHandler.php', '/bin/relution-sync.php'] as $datei) {
$src = (string)file_get_contents($wurzel . $datei);
assertFalse(
str_contains($src, 'new RelutionSyncService('),
"Baut den Dienst am Runner vorbei: {$datei}"
);
}
});
test('Auch die Ausmusterung eines verschwundenen Geräts wird festgehalten', function () use ($wurzel) {
// Ein Gerät, das plötzlich ausgemustert ist, braucht eine Erklärung im
// eigenen Lebenslauf – nicht nur im Protokoll des Laufs.
foreach (['IntuneSyncService', 'RelutionSyncService'] as $dienst) {
$src = (string)file_get_contents($wurzel . "/src/Services/{$dienst}.php");
$von = strpos($src, 'decommissionMissing(array');
$block = substr($src, (int)$von);
assertTrue(str_contains($block, 'protokolliereAusmusterung($row)'), "Keine Meldung in {$dienst}");
// Der Vorzustand muss den alten Status tragen, sonst stünde dort
// „Ausgemustert → Ausgemustert".
assertTrue((bool)preg_match('/SELECT id,[^"]*status FROM assets/', $block), "Status nicht geladen in {$dienst}");
}
});
test('Eine Ausmusterung ergibt einen lesbaren Statuswechsel', function () use ($db, $logger) {
$audit = $logger();
MdmAssetChanges::schreibe(
$db(),
$audit,
'intune',
42,
['inventory_number' => 'IT-1', 'status' => 'active'],
['inventory_number' => 'IT-1', 'status' => 'decommissioned']
);
assertSame(['Status' => 'Aktiv'], $audit->eintraege[0]['alt']);
assertSame(['Status' => 'Ausgemustert'], $audit->eintraege[0]['neu']);
});
test('Beide Abgleiche merken sich die gemeldete Person', function () use ($wurzel) {
// Ohne diesen Merker ist nicht unterscheidbar, ob die Zuweisung vom MDM
// stammt oder von Hand gepflegt wurde.
foreach (['IntuneSyncService', 'RelutionSyncService'] as $dienst) {
$src = (string)file_get_contents($wurzel . "/src/Services/{$dienst}.php");
assertTrue(str_contains($src, 'mdm_person_id'), "Kein Merker in {$dienst}");
}
$migration = (string)file_get_contents($wurzel . '/database/migrations/209_assets_mdm_person.sql');
assertTrue(str_contains($migration, 'ADD COLUMN `mdm_person_id`'));
});
// ----------------------------------------------------------------
// Gesperrte Zuweisung
// ----------------------------------------------------------------
test('Die Zuweisung gilt nur als MDM-geführt, wenn das MDM eine Person meldet', function () {
assertTrue(AssetController::mdmFuehrtZuweisung(['mdm_source' => 'intune', 'mdm_person_id' => 7]));
// Verwaltet, aber ohne gemeldete Person: der Abgleich lässt die
// Zuweisung stehen, also bleibt sie pflegbar.
assertFalse(AssetController::mdmFuehrtZuweisung(['mdm_source' => 'intune', 'mdm_person_id' => null]));
assertFalse(AssetController::mdmFuehrtZuweisung(['mdm_source' => 'intune', 'mdm_person_id' => 0]));
// Gar kein MDM.
assertFalse(AssetController::mdmFuehrtZuweisung(['mdm_source' => '', 'mdm_person_id' => 7]));
assertFalse(AssetController::mdmFuehrtZuweisung([]));
});
test('Das Formular sperrt das Feld und erklärt warum', function () use ($wurzel) {
$tpl = (string)file_get_contents($wurzel . '/templates/assets/form.php');
assertTrue(str_contains($tpl, '$mdmFuehrt'), 'Keine Fallunterscheidung im Formular');
assertTrue(str_contains($tpl, 'readonly'), 'Feld nicht gesperrt');
assertTrue(str_contains($tpl, 'würde der'), 'Kein Hinweis auf den Grund');
});
test('Das gesperrte Feld zeigt die Person, nicht nichts', function () use ($wurzel) {
// Ein Abgleich setzt nur `assigned_person_id`; das Freitextfeld
// `assigned_to` bleibt dabei leer. Ohne den aufgelösten Namen stünde
// dort ein leeres Feld mit dem Hinweis, es werde vom MDM geführt.
$tpl = (string)file_get_contents($wurzel . '/templates/assets/form.php');
$von = strpos($tpl, 'if ($mdmFuehrt)');
$bis = strpos($tpl, '<?php else: ?>', (int)$von);
$block = substr($tpl, (int)$von, (int)$bis - (int)$von);
assertTrue(str_contains($block, "assigned_person_name"), 'Name wird im gesperrten Feld nicht genutzt');
// Und der Controller muss ihn liefern – das Formular lädt nur `SELECT *`.
$src = (string)file_get_contents($wurzel . '/src/Controllers/AssetController.php');
$von = strpos($src, 'public function edit(');
$bis = strpos($src, "'assets/form'", (int)$von);
$editBlock = substr($src, (int)$von, (int)$bis - (int)$von);
assertTrue(str_contains($editBlock, 'FROM directory_persons WHERE id = ?'), 'Name wird nicht aufgelöst');
assertTrue(str_contains($editBlock, "\$asset['assigned_person_name']"), 'Name wird nicht übergeben');
});
test('Ein trotzdem gesendeter Wert wird verworfen', function () use ($wurzel) {
// Das gesperrte Feld ist nur die Anzeige; maßgeblich ist der Server.
$src = (string)file_get_contents($wurzel . '/src/Controllers/AssetController.php');
$von = strpos($src, 'public function update(');
$bis = strpos($src, 'Inventarnummer-Änderung prüfen', (int)$von);
$block = substr($src, (int)$von, (int)$bis - (int)$von);
assertTrue(str_contains($block, 'mdmFuehrtZuweisung($asset)'), 'Keine Prüfung in update()');
assertTrue(str_contains($block, "\$data['assigned_to'] = \$asset['assigned_to'];"), 'Wert nicht zurückgesetzt');
assertTrue(str_contains($block, "\$data['assigned_person_id'] = \$asset['assigned_person_id'];"));
});
};