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