OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

MdmAssetChangesTest.php

16 KB
<?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'];"));
    });
};