OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

IntunePrimaryUserTest.php

22 KB
<?php

declare(strict_types=1);

/**
 * Deckt den primären Benutzer und das Zurückschreiben nach Intune ab.
 *
 * Intune führt zwei Personen je Gerät: `userPrincipalName` am Gerät nennt die
 * Person, die es registriert hat, die Beziehung `users` die, der es zugedacht
 * ist. Wo die IT Geräte vorbereitet, sind das verschiedene.
 *
 * Daraus folgt die Falle, gegen die diese Fälle laufen: Schreibt OpenNIT den
 * primären Benutzer, liest aber weiter die registrierende Person, dann setzt
 * der nächste Abgleich die Änderung still zurück. Das sieht aus wie ein
 * geglückter Vorgang und macht sich später von selbst rückgängig.
 */
return static function (): void {

    $wurzel  = dirname(__DIR__);
    $service = (string)file_get_contents($wurzel . '/src/Services/IntuneSyncService.php');
    $conn    = (string)file_get_contents($wurzel . '/src/Services/IntuneConnection.php');
    $admin   = (string)file_get_contents($wurzel . '/src/Controllers/Admin/IntuneAdminController.php');
    $asset   = (string)file_get_contents($wurzel . '/src/Controllers/AssetController.php');

    /** Rumpf einer Methode bis zur nächsten gleicher Einrückung. */
    $rumpf = static function (string $quelle, string $methode): string {
        $start = strpos($quelle, 'function ' . $methode . '(');
        if ($start === false) {
            return '';
        }
        $rest = substr($quelle, $start);
        $naechste = preg_match('/\n    (?:public|private|protected|\/\*\*)/', $rest, $m, PREG_OFFSET_CAPTURE)
            ? $m[0][1] : strlen($rest);
        return substr($rest, 0, $naechste);
    };

    // ----------------------------------------------------------------
    // Die Falle: Schreiben ohne passendes Lesen
    // ----------------------------------------------------------------

    test('Zurückschreiben gibt es nur mit dem Lesen des primären Benutzers', function () use ($conn) {
        // Sonst läse der nächste Abgleich wieder die registrierende Person.
        assertTrue(
            str_contains($conn, "(\$stored['intune_writeback_user'] ?? '0') === '1'")
            && str_contains($conn, "(\$stored['intune_primary_user'] ?? '0') === '1'"),
            'Das Zurückschreiben hängt nicht am Lesen des primären Benutzers'
        );
    });

    test('Die Einstellungsseite hält beide Schalter zusammen', function () use ($admin) {
        // Wer nur das Zurückschreiben anhakt, bekommt das andere mit dazu –
        // statt einer Einstellung, die nichts bewirkt.
        $body = substr($admin, (int)strpos($admin, 'intune_writeback_user'), 900);
        assertTrue(str_contains($body, "\$primaryUser = '1';"), 'Der zweite Schalter wird nicht mitgesetzt');
        assertTrue(str_contains($body, 'flash('), 'Die stille Änderung wird nicht benannt');
    });

    // ----------------------------------------------------------------
    // Lesen
    // ----------------------------------------------------------------

    test('Ohne Umstellung bleibt es bei der registrierenden Person', function () use ($service) {
        // Bestandsinstanzen sollen sich durch das Update nicht ändern.
        assertTrue(str_contains($service, 'private readonly bool     $primaryUser = false'), 'Die Umstellung ist nicht abschaltbar');
        assertTrue(str_contains($service, '$userEmail = $enrolledEmail;'), 'Die registrierende Person ist nicht der Ausgangswert');
    });

    test('Ohne primären Benutzer bleibt die bisherige Zuordnung', function () use ($service) {
        // Ein Gerät ohne primären Benutzer soll seine Person nicht verlieren.
        $stelle = strpos($service, '$primary = $this->primaryUserEmail(');
        assertTrue($stelle !== false, 'Der primäre Benutzer wird nicht gelesen');
        $block = substr($service, $stelle, 200);
        assertTrue(str_contains($block, "if (\$primary !== '')"), 'Ein leerer Wert überschreibt die Zuordnung');
    });

    test('Der Abgleich bricht nicht ab, wenn Graph die Beziehung nicht mitliefert', function () use ($service, $rumpf) {
        // Ob $expand=users durchgeht, hängt an Mandant und Version. Ein „nein"
        // darf den Abgleich nicht kosten – er holt den Wert dann je Gerät.
        $body = $rumpf($service, 'fetchAllDevices');
        assertTrue(str_contains($body, '$expand=users'), 'Es wird gar nicht erst versucht');
        assertTrue(str_contains($body, '$this->primaryUserPerDevice = true;'), 'Es gibt keinen Rückfallweg');
        // Der Rückfall greift auch, wenn Graph ohne Fehler einfach nichts mitschickt.
        assertTrue(str_contains($body, "!array_key_exists('users', \$all[0])"), 'Eine stille Auslassung fällt nicht auf');
    });

    test('Ein fehlgeschlagener Einzelabruf kostet nicht den Abgleich', function () use ($service, $rumpf) {
        $body = $rumpf($service, 'fetchPrimaryUsers');
        assertTrue($body !== '', 'Der Einzelabruf fehlt');
        // Jeder Weg endet mit einer Liste – nie mit null, das der Aufrufer
        // dann gesondert behandeln müsste.
        assertTrue(str_contains($body, ': array'), 'Der Einzelabruf verspricht keine Liste');
        assertFalse(str_contains($body, 'return null'), 'Ein Fehlerweg liefert kein Ergebnis zurück');
        // Ein Fehlschlag darf nicht als Abgleich-Fehler gezählt werden.
        assertFalse(str_contains($body, '$this->errors[]'), 'Ein Einzelabruf bricht den Abgleich ab');
    });

    // ----------------------------------------------------------------
    // Schreiben
    // ----------------------------------------------------------------

    test('Erst nach Intune, dann in die eigene Datenbank', function () use ($asset) {
        // Andersherum stünden bei einem Fehlschlag hier und dort verschiedene
        // Personen, ohne dass es jemandem auffiele.
        $stelle = strpos($asset, '$ergebnis = $this->intuneZuweisungSetzen(');
        assertTrue($stelle !== false, 'Es wird nicht zurückgeschrieben');
        $block = substr($asset, $stelle, 600);
        assertTrue(str_contains($block, "if (!\$ergebnis['ok'])"), 'Ein Fehlschlag wird übergangen');
        assertTrue(str_contains($block, 'return;'), 'Nach einem Fehlschlag wird trotzdem gespeichert');
    });

    test('Nur Personen aus dem Entra-Verzeichnis', function () use ($asset) {
        // Intune kennt keine Personen aus LDAP oder einem CSV-Import.
        $stelle = strpos($asset, 'private function intuneZuweisungSetzen(');
        $body   = substr($asset, $stelle, 2000);
        assertTrue(str_contains($body, "(\$person['source'] ?? '') !== 'entra'"), 'Die Quelle der Person wird nicht geprüft');
        assertTrue(str_contains($body, "trim((string)(\$person['external_id'] ?? '')) === ''"), 'Die Entra-Kennung wird nicht geprüft');
        assertTrue(str_contains($body, 'stammt aus einer anderen Quelle'), 'Der Grund wird nicht benannt');
    });

    test('Das Zurückschreiben steht im Protokoll – Erfolg wie Fehlschlag', function () use ($asset) {
        $stelle = strpos($asset, 'private function intuneZuweisungSetzen(');
        $body   = substr($asset, $stelle, 2500);
        assertTrue(str_contains($body, 'intune_primary_user_set'), 'Ein Erfolg wird nicht protokolliert');
        assertTrue(str_contains($body, 'intune_primary_user_failed'), 'Ein Fehlschlag wird nicht protokolliert');
    });

    test('Der Vermerk der Quelle wandert mit', function () use ($asset) {
        // mdm_person_id hält fest, wen das MDM gemeldet hat. Bliebe der alte
        // Wert stehen, sähe der nächste Abgleich einen Unterschied, den es
        // nicht gibt.
        $stelle = strpos($asset, '$ergebnis = $this->intuneZuweisungSetzen(');
        $block  = substr($asset, $stelle, 700);
        assertTrue(str_contains($block, "\$data['mdm_person_id'] = \$neuePerson;"), 'Der Vermerk der Quelle bleibt alt stehen');
    });

    test('Ohne Rückschreiben bleibt das Feld gesperrt', function () use ($asset) {
        // Das bisherige Verhalten gilt weiter, solange nichts eingeschaltet ist.
        $stelle = strpos($asset, 'elseif (self::mdmFuehrtZuweisung($asset))');
        assertTrue($stelle !== false, 'Der bisherige Weg fehlt');
        $block = substr($asset, $stelle, 400);
        assertTrue(str_contains($block, "\$data['assigned_person_id'] = \$asset['assigned_person_id'];"), 'Der gesendete Wert wird nicht mehr verworfen');
    });

    test('Eine entfernte Person wird auch in Intune geleert', function () use ($asset, $service) {
        // Ohne Person ist die Beziehung zu entfernen, nicht nur lokal zu leeren –
        // sonst behielte Intune den alten primären Benutzer.
        $stelle = strpos($asset, 'private function intuneZuweisungSetzen(');
        $body   = substr($asset, $stelle, 2500);
        assertTrue(str_contains($body, '$objektId = null;'), 'Ohne Person gibt es keine leere Kennung');
        assertTrue(str_contains($body, 'if ($personId !== null)'), 'Der Fall ohne Person wird nicht behandelt');

        // Im Dienst führt die leere Kennung auf das Entfernen der Beziehung.
        $set = substr($service, (int)strpos($service, 'public function setPrimaryUser('), 2500);
        assertTrue(str_contains($set, "\$entraObjectId === null || trim(\$entraObjectId) === ''"), 'Die leere Kennung führt nicht zum Entfernen');
        assertTrue(str_contains($set, "\$this->httpRequest('DELETE'"), 'Es wird nichts entfernt');
    });

    test('Der Weg hängt am Gerät, nicht an der aktuellen Meldung', function () use ($asset) {
        // Nach einmaligem Entfernen meldet das MDM niemanden mehr. Haenge das
        // Zurueckschreiben daran, liefe die naechste Zuweisung nur noch lokal.
        $rueck = strpos($asset, 'if ($this->intuneRueckschreibenMoeglich($asset))');
        $gefuehrt = strpos($asset, 'elseif (self::mdmFuehrtZuweisung($asset))');
        assertTrue($rueck !== false && $gefuehrt !== false, 'Die beiden Wege fehlen');
        assertTrue($rueck < $gefuehrt, 'Das Zurückschreiben steht hinter der Meldung des MDM');

        // intuneRueckschreibenMoeglich() darf mdm_person_id nicht heranziehen.
        $body = substr($asset, (int)strpos($asset, 'private function intuneRueckschreibenMoeglich('), 800);
        assertFalse(str_contains($body, 'mdm_person_id'), 'Der Weg hängt an der aktuellen Meldung');
    });

    test('Zurückgeschrieben wird nur bei Intune-Geräten mit Kennung', function () use ($asset) {
        $stelle = strpos($asset, 'private function intuneRueckschreibenMoeglich(');
        $body   = substr($asset, $stelle, 800);
        assertTrue(str_contains($body, "=== 'intune'"), 'Andere Quellen werden mitgenommen');
        assertTrue(str_contains($body, "trim((string)(\$asset['mdm_id'] ?? '')) !== ''"), 'Ein Gerät ohne Kennung wird versucht');
    });

    // ----------------------------------------------------------------
    // Löschen
    // ----------------------------------------------------------------

    test('Erst aus Intune entfernen, dann örtlich löschen', function () use ($asset) {
        // Andersherum wäre das Gerät in OpenNIT weg und liefe in Intune weiter –
        // niemand käme mehr auf die Idee, dort nachzusehen.
        $destroy = strpos($asset, 'public function destroy(');
        assertTrue($destroy !== false, 'destroy() fehlt');
        $stelle = strpos($asset, '$this->intuneLoeschenMoeglich($asset)', $destroy);
        assertTrue($stelle !== false, 'Es wird nichts entfernt');
        $block = substr($asset, $stelle, 1600);

        $pruefung = strpos($block, "if (!\$ergebnis['ok'])");
        $loeschen = strpos($block, "DELETE FROM assets");
        assertTrue($pruefung !== false, 'Ein Fehlschlag wird übergangen');
        assertTrue($loeschen !== false && $pruefung < $loeschen, 'Es wird gelöscht, bevor Intune geantwortet hat');
        assertTrue(str_contains($block, 'auch in OpenNIT nicht gelöscht'), 'Der Abbruch wird nicht erklärt');
    });

    test('Das Sammel-Löschen bleibt örtlich', function () use ($asset) {
        // Ein Fehlgriff dort soll nicht dutzende Geräte aus der Verwaltung nehmen.
        $stelle = strpos($asset, 'Massen-Löschung');
        assertTrue($stelle !== false, 'Die Sammel-Löschung fehlt');
        $block = substr($asset, $stelle, 4000);
        assertFalse(str_contains($block, 'intuneLoeschenMoeglich'), 'Die Sammel-Löschung greift nach Intune');
    });

    test('Ein dort bereits fehlendes Gerät gilt als erledigt', function () use ($service) {
        // Sonst blockierte ein längst entferntes Gerät das Aufräumen hier.
        $body = substr($service, (int)strpos($service, 'public function removeDevice('), 2500);
        assertTrue(str_contains($body, '$status === 404'), 'Ein fehlendes Gerät gilt als Fehler');
        assertTrue(str_contains($body, 'bereits nicht mehr vorhanden'), 'Der Sonderfall wird nicht benannt');
    });

    test('Das Entfernen steht als schwerwiegend im Protokoll', function () use ($asset) {
        $body = substr($asset, (int)strpos($asset, 'private function intuneGeraetEntfernen('), 1500);
        assertTrue(str_contains($body, 'intune_device_removed'), 'Ein Erfolg wird nicht protokolliert');
        assertTrue(str_contains($body, 'intune_device_remove_failed'), 'Ein Fehlschlag wird nicht protokolliert');
        assertTrue(str_contains($body, "'high'"), 'Der Eingriff gilt nicht als schwerwiegend');
    });

    test('Das Entfernen ist abschaltbar und aus', function () use ($conn, $wurzel) {
        assertTrue(str_contains($conn, "(\$stored['intune_delete_device'] ?? '0') === '1'"), 'Es gibt keinen Schalter');
        $seite = (string)file_get_contents($wurzel . '/templates/admin/inventory/intune.php');
        assertTrue(str_contains($seite, 'name="intune_delete_device"'), 'Der Schalter fehlt in der Einrichtung');
        // Die Tragweite gehört an den Schalter, nicht in die Dokumentation.
        assertTrue(str_contains($seite, 'weder'), 'Die Tragweite wird nicht benannt');
    });

    // ----------------------------------------------------------------
    // Wipe oder Retire: die Wahl beim Löschen
    // ----------------------------------------------------------------

    test('Die drei Wege stehen an einer Stelle und tragen alles Nötige', function () use ($service) {
        $wege = \OpenNIT\Services\IntuneSyncService::REMOVAL_ACTIONS;
        assertSame(['delete', 'retire', 'wipe'], array_keys($wege));

        foreach ($wege as $key => $weg) {
            foreach (['label', 'method', 'success', 'rejected'] as $feld) {
                assertTrue(!empty($weg[$feld]), "{$key}: {$feld} fehlt");
            }
            // Pfad und Rumpf dürfen leer bzw. null sein – müssen aber gesetzt sein.
            foreach (['path', 'body'] as $feld) {
                assertTrue(array_key_exists($feld, $weg), "{$key}: {$feld} fehlt");
            }
        }

        // Die Adressen: nur beim Entfernen des Datensatzes ist es ein DELETE.
        assertSame('DELETE', $wege['delete']['method']);
        assertSame('', $wege['delete']['path']);
        assertSame('POST', $wege['retire']['method']);
        assertSame('/retire', $wege['retire']['path']);
        assertSame('POST', $wege['wipe']['method']);
        assertSame('/wipe', $wege['wipe']['path']);

        // Das Zurücksetzen behält weder Anmeldung noch Benutzerdaten.
        assertSame('{"keepEnrollmentData":false,"keepUserData":false}', $wege['wipe']['body']);
        assertSame(null, $wege['delete']['body'], 'Ein DELETE trägt keinen Rumpf');
    });

    test('Ein unbekannter Weg führt zu keinem Aufruf', function () use ($service, $asset, $conn) {
        // Die Auswahl kommt aus dem Formular und ist damit frei wählbar.
        assertTrue(
            str_contains($service, '$weg = self::REMOVAL_ACTIONS[$action] ?? null;'),
            'Der Dienst nimmt jeden Wert an'
        );
        assertTrue(
            str_contains($asset, 'if (!isset($wege[$aktion])) {'),
            'Der Controller nimmt jeden Wert an'
        );
        // Und die Einstellung fällt auf den leichtesten Eingriff zurück,
        // nicht auf einen schwereren.
        assertTrue(
            str_contains($conn, "? \$wert : 'delete';"),
            'Ein veränderter Eintrag könnte auf ein Zurücksetzen hinauslaufen'
        );
    });

    test('Die Einstellung ist nur die Vorauswahl, entschieden wird je Gerät', function () use ($asset, $wurzel) {
        $rumpfBehandlung = substr($asset, (int)strpos($asset, 'private function intuneBehandlung('), 600);

        // Erst die Auswahl am Gerät, dann die hinterlegte Vorgabe.
        $auswahl = strpos($rumpfBehandlung, "post('intune_action')");
        $vorgabe = strpos($rumpfBehandlung, 'deleteAction(');
        assertTrue($auswahl !== false, 'Die Auswahl am Gerät wird nicht gelesen');
        assertTrue($vorgabe !== false, 'Die Vorgabe wird nicht gelesen');
        assertTrue($auswahl < $vorgabe, 'Die Vorgabe geht der Auswahl vor');

        $seite = (string)file_get_contents($wurzel . '/templates/assets/show.php');
        assertTrue(str_contains($seite, 'name="intune_action"'), 'Die Seite fragt nicht nach der Behandlung');
        assertTrue(str_contains($seite, 'name="_csrf"'), 'Ohne Token schlägt das Absenden fehl');

        $admin = (string)file_get_contents($wurzel . '/templates/admin/inventory/intune.php');
        assertTrue(str_contains($admin, 'name="intune_delete_action"'), 'Die Vorauswahl fehlt in der Einrichtung');
    });

    test('Das Zurücksetzen verlangt eine eigene Bestätigung', function () use ($wurzel) {
        // Es löscht alle Daten auf einem echten Gerät und lässt sich nicht
        // zurücknehmen; ein Fehlklick in der Auswahl darf nicht genügen.
        $seite = (string)file_get_contents($wurzel . '/templates/assets/show.php');
        assertTrue(str_contains($seite, 'inv-delete-wipe-ack'), 'Es gibt keine eigene Bestätigung');
        assertTrue(
            str_contains($seite, "absenden.disabled = istWipe && !haken.checked;"),
            'Ohne Bestätigung lässt sich trotzdem absenden'
        );
    });

    test('Die Behandlung steht im Protokoll', function () use ($asset) {
        // „Gelöscht" allein sagt nicht, ob ein Gerät zurückgesetzt wurde.
        $body = substr($asset, (int)strpos($asset, 'private function intuneGeraetEntfernen('), 1600);
        assertTrue(str_contains($body, "'behandlung' => \$aktion"), 'Der gewählte Weg fehlt im Protokoll');
        assertTrue(str_contains($body, "'high'"), 'Der Eingriff gilt nicht als schwerwiegend');
    });

    test('Der eingestellte Auftrag ist kein sofortiger Vollzug', function () use ($service, $wurzel) {
        // retire und wipe stellen einen Auftrag ein, den das Gerät beim
        // nächsten Kontakt ausführt – wer das für vollzogen hält, sucht den
        // Datensatz in Intune vergeblich als verschwunden.
        assertTrue(str_contains($service, 'beim naechsten Kontakt'), 'Der Unterschied steht nicht am Code');

        $admin = (string)file_get_contents($wurzel . '/templates/admin/inventory/intune.php');
        assertTrue(str_contains($admin, 'Auftrag'), 'Der Unterschied steht nicht an der Einstellung');
    });

    // ----------------------------------------------------------------
    // Oberfläche
    // ----------------------------------------------------------------

    test('Das Feld wird bedienbar, sobald zurückgeschrieben wird', function () use ($wurzel) {
        $form = (string)file_get_contents($wurzel . '/templates/assets/form.php');
        assertTrue(str_contains($form, '$mdmRueck    = !empty($mdm_writeback);'), 'Das Formular kennt den Schalter nicht');
        assertTrue(str_contains($form, '&& !$mdmRueck;'), 'Das Feld bleibt trotz Rückschreiben gesperrt');
        assertTrue(str_contains($form, 'als primärer Benutzer nach'), 'Der Weg der Änderung wird nicht erklärt');
    });

    test('Die Detailseite kennzeichnet fremdgeführte Geräte', function () use ($wurzel) {
        $seite = (string)file_get_contents($wurzel . '/templates/assets/show.php');

        // Nur der Badge-Abschnitt zählt: `last_sync_at` kommt weiter unten auf
        // derselben Seite berechtigterweise vor (Stand des Inventaragenten).
        $von   = strpos($seite, '$mdmQuellen = ');
        $bis   = $von === false ? false : strpos($seite, '</h1>', $von);
        assertTrue($von !== false && $bis !== false, 'Der Badge-Abschnitt ist nicht auffindbar');
        $badge = substr($seite, $von, $bis - $von);

        assertTrue(str_contains($badge, 'badge badge-managed'), 'Es gibt kein Badge');
        assertTrue(str_contains($badge, '-verwaltet'), 'Das Badge nennt die Quelle nicht');

        // Der Zeitpunkt kommt aus `mdm_last_seen_at` – dort halten ihn beide
        // MDM-Abgleiche fest. `last_sync_at` gehört dem Inventaragenten und
        // sagt über den MDM-Stand nichts aus; die Zusatzfelder sind nach
        // Feld-Kennung indiziert, ein Zugriff über den Namen bliebe leer.
        assertTrue(str_contains($badge, "\$asset['mdm_last_seen_at']"), 'Das Badge liest den falschen Zeitpunkt');
        assertFalse(str_contains($badge, "empty(\$asset['last_sync_at'])"), 'Das Badge zeigt den Stand des Inventaragenten');
        assertFalse(str_contains($badge, "custom_field_values['intune_last_sync']"), 'Das Badge greift auf die Zusatzfelder über den Namen zu');

        // Beide Quellen führen zum selben Badge, und bei mehreren Instanzen
        // sagt „Relution" allein nicht, aus welcher.
        assertTrue(str_contains($badge, "'relution' => 'Relution'"), 'Relution führt zu keinem Badge');
        assertTrue(str_contains($badge, "\$asset['mdm_connection_name']"), 'Das Badge nennt die Instanz nicht');
        assertTrue(str_contains($badge, '$mdmTitel'), 'Der Tooltip nennt die Instanz nicht');

        // Zentrale Komponente statt Inline-Farben.
        $css = (string)file_get_contents($wurzel . '/public/assets/css/app.css');
        assertTrue(str_contains($css, '.badge-managed'), 'Die Komponente fehlt in app.css');
        assertFalse((bool)preg_match('/badge badge-managed"[^>]*style="/', $seite), 'Das Badge bringt eigene Farben mit');

        $styleguide = (string)file_get_contents($wurzel . '/STYLEGUIDE.md');
        assertTrue(str_contains($styleguide, '.badge-managed'), 'Die Komponente ist nicht dokumentiert');
    });

    test('Das Wolken-Icon steht in der Icon-Zuordnung', function () use ($wurzel) {
        // Ohne Eintrag bliebe es im Emoji-Modus monochrom.
        $map = (string)file_get_contents($wurzel . '/src/Core/IconMap.php');
        foreach (['fa-cloud', 'fa-exchange-alt'] as $icon) {
            assertTrue(str_contains($map, "'{$icon}'"), "Icon {$icon} fehlt in der Zuordnung");
        }
    });
};