Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Core\Database;
use OpenNIT\Directory\DirectoryConnectionManager;
use OpenNIT\Directory\DirectorySync;
/**
* Deckt mehrere Verzeichnis-Anbindungen nebeneinander ab.
*
* Der Abgleich legt Personen still, die das Verzeichnis nicht mehr meldet.
* Diese Regel war an die Art der Quelle gebunden – bei zwei LDAP-Anbindungen
* hätte damit jeder Lauf die Personen der jeweils anderen stillgelegt, und
* zwar lautlos: Der Lauf meldet Erfolg, die Geräte- und Ticket-Zuordnungen
* zeigen ins Leere.
*
* Das ist der Fall, gegen den die Fälle hier laufen. Der zweite ist das
* Wiederfinden: Dieselbe Kennung darf in zwei Verzeichnissen vorkommen, ohne
* dass die eine Anbindung den Eintrag der anderen überschreibt.
*/
return static function (): void {
$wurzel = dirname(__DIR__);
$quelle = static fn (string $pfad): string => (string)file_get_contents($wurzel . '/' . $pfad);
// ----------------------------------------------------------------
// Die Abmeldung bleibt bei ihrer Anbindung
// ----------------------------------------------------------------
/**
* Datenbank-Attrappe, die Personen im Speicher führt – genug, um
* upsertPersons() gegen echte Abfragen laufen zu lassen.
*/
$db = static function (array $personen): Database {
return new class($personen) extends Database {
/** @var list<array<string,mixed>> */
public array $personen;
/** @var list<string> */
public array $updates = [];
private int $naechsteId = 100;
public function __construct(array $personen)
{
$this->personen = $personen;
parent::__construct([]);
}
public function fetchOne(string $sql, array $params = []): ?array
{
if (str_contains($sql, 'connection_id = ? AND external_id = ?')) {
foreach ($this->personen as $p) {
if ((int)$p['connection_id'] === (int)$params[0] && $p['external_id'] === $params[1]) {
return ['id' => $p['id']];
}
}
return null;
}
if (str_contains($sql, 'source = ? AND external_id = ?')) {
foreach ($this->personen as $p) {
if ($p['source'] === $params[0] && $p['external_id'] === $params[1]) {
return ['id' => $p['id']];
}
}
}
return null;
}
public function fetchAll(string $sql, array $params = []): array
{
return [];
}
public function insert(string $table, array $data): int
{
$data['id'] = $this->naechsteId++;
$this->personen[] = $data;
return (int)$data['id'];
}
public function update(string $table, array $data, string $where, array $whereParams = []): int
{
return 1;
}
public function execute(string $sql, array $params = []): \PDOStatement
{
// Die Abmeldung: festhalten, wonach eingegrenzt wurde.
if (str_contains($sql, 'SET is_active = 0')) {
$this->updates[] = $sql . ' || ' . implode(',', array_map('strval', $params));
}
return new class extends \PDOStatement {
public function rowCount(): int
{
return 0;
}
};
}
};
};
/** Ruft upsertPersons() für eine Anbindung auf. */
$abgleich = static function (Database $db, int $connectionId, string $source, array $liste): void {
$ref = new ReflectionClass(DirectorySync::class);
$sync = $ref->newInstanceWithoutConstructor();
foreach (['db' => $db, 'config' => []] as $name => $wert) {
$prop = $ref->getProperty($name);
$prop->setAccessible(true);
$prop->setValue($sync, $wert);
}
$prop = $ref->getProperty('connectionId');
$prop->setAccessible(true);
$prop->setValue($sync, $connectionId);
$methode = $ref->getMethod('upsertPersons');
$methode->setAccessible(true);
$methode->invoke($sync, $source, $liste, fn (array $r): array => $r);
};
test('Ein Lauf legt nur Personen der eigenen Anbindung still', function () use ($db, $abgleich) {
// Zwei LDAP-Anbindungen nebeneinander: Der Lauf der einen darf die
// Personen der anderen nicht anfassen.
$datenbank = $db([
['id' => 1, 'connection_id' => 7, 'source' => 'ldap', 'external_id' => 'CN=Anna'],
['id' => 2, 'connection_id' => 8, 'source' => 'ldap', 'external_id' => 'CN=Bert'],
]);
$abgleich($datenbank, 7, 'ldap', [
['external_id' => 'CN=Anna', 'display_name' => 'Anna'],
]);
assertSame(1, count($datenbank->updates), 'Es wurde nicht genau einmal abgemeldet');
$abmeldung = $datenbank->updates[0];
assertTrue(str_contains($abmeldung, 'connection_id = ?'), 'Die Abmeldung grenzt nicht auf die Anbindung ein');
assertFalse(str_contains($abmeldung, 'source = ?'), 'Die Abmeldung greift über die Art');
assertTrue(str_contains($abmeldung, '|| 7,'), 'Es wurde nicht auf Anbindung 7 eingegrenzt');
});
test('Dieselbe Kennung in zwei Verzeichnissen bleibt getrennt', function () use ($db, $abgleich) {
// Zwei Kunden können denselben DN führen. Die zweite Anbindung darf
// den Eintrag der ersten nicht übernehmen.
$datenbank = $db([
['id' => 1, 'connection_id' => 7, 'source' => 'ldap', 'external_id' => 'CN=Administrator'],
]);
$abgleich($datenbank, 8, 'ldap', [
['external_id' => 'CN=Administrator', 'display_name' => 'Administrator (Kunde B)'],
]);
// Es muss ein zweiter Eintrag entstanden sein, kein überschriebener.
assertSame(2, count($datenbank->personen), 'Der vorhandene Eintrag wurde übernommen statt ein neuer angelegt');
$neu = $datenbank->personen[1];
assertSame(8, $neu['connection_id'], 'Der neue Eintrag hängt an der falschen Anbindung');
});
test('Eine neue Person bekommt ihre Anbindung mit', function () use ($db, $abgleich) {
$datenbank = $db([]);
$abgleich($datenbank, 42, 'entra', [
['external_id' => 'abc-123', 'display_name' => 'Neue Person'],
]);
assertSame(1, count($datenbank->personen));
assertSame(42, $datenbank->personen[0]['connection_id'], 'Die Herkunft fehlt');
assertSame('entra', $datenbank->personen[0]['source']);
});
// ----------------------------------------------------------------
// Einstellungen je Anbindung
// ----------------------------------------------------------------
test('Eine zweite LDAP-Anbindung erbt nichts von der ersten', function () use ($quelle) {
// Würde die Konfiguration der Anwendung durchschlagen, liefe die
// zweite Anbindung still gegen den Server der ersten.
$src = $quelle('src/Directory/DirectorySync.php');
$start = strpos($src, 'public static function forConnection(');
assertTrue($start !== false, 'forConnection() fehlt');
$body = substr($src, $start, 1800);
assertTrue(str_contains($body, "\$config['ldap'] = \$einstell;"), 'LDAP erbt die alte Konfiguration');
assertTrue(str_contains($body, "\$sync->connectionId = "), 'Die Anbindung wird nicht mitgegeben');
});
test('Eine Anbindung ohne Attribut-Zuordnung läuft ohne Warnungen', function () use ($quelle) {
// Die Anbindung speichert nur, was ausgefüllt ist – die frühere
// Konfigurationsdatei trug dagegen jeden Schlüssel. Ein Lesezugriff mit
// `?:` auf einen fehlenden Schlüssel meldet eine Warnung, und die
// Ausgabe verhindert die Weiterleitung nach dem Abgleich.
$src = $quelle('src/Directory/DirectorySync.php');
assertSame(
0,
preg_match_all("/\\\$this->config\\['ldap'\\]\\['\\w+'\\]\\s*\\?:/", $src),
'Ein LDAP-Schlüssel wird ohne Ausweichwert gelesen'
);
});
test('Die Art einer bestehenden Anbindung lässt sich nicht umstellen', function () use ($quelle) {
// Die Kennungen der übernommenen Personen gehören zur Art; ein Wechsel
// ließe sie ins Leere zeigen.
$ctrl = $quelle('src/Controllers/Admin/DirectoryAdminController.php');
$start = strpos($ctrl, 'public function connectionSave(');
$body = substr($ctrl, (int)$start, 2200);
assertTrue(str_contains($body, "\$typ = (string)\$bisher['type'];"), 'Die Art wird aus dem Formular übernommen');
$tpl = $quelle('templates/admin/directory/connection_form.php');
assertTrue(str_contains($tpl, "\$c !== null ? 'disabled' : ''"), 'Das Formular lässt die Art umstellen');
});
// ----------------------------------------------------------------
// Übernahme der früheren Einzelanbindung
// ----------------------------------------------------------------
test('Eine hinterlegte Anbindung wird nur übernommen, solange keine steht', function () use ($quelle) {
// Sonst überschriebe die alte Konfigurationsdatei bei jedem Aufruf,
// was seither in der Oberfläche eingerichtet wurde.
$src = $quelle('src/Directory/DirectoryConnectionManager.php');
$start = strpos($src, 'public function adoptLegacyConfig(');
$body = substr($src, (int)$start, 1400);
assertTrue(
str_contains($body, '$this->einstellungen($vorhanden) !== []'),
'Eine eingerichtete Anbindung wird überschrieben'
);
assertTrue(str_contains($body, 'return false;'), 'Es gibt keinen Rückweg ohne Änderung');
});
test('Die Übernahme läuft auch ohne Besuch der Einstellungsseite', function () use ($quelle) {
// Der Zeitplan darf nicht darauf warten, dass jemand die Seite öffnet.
$runner = $quelle('src/Directory/DirectorySyncRunner.php');
assertTrue(str_contains($runner, 'adoptLegacyConfig('), 'Der Lauf übernimmt die frühere Anbindung nicht');
});
// ----------------------------------------------------------------
// Der Lauf über mehrere Anbindungen
// ----------------------------------------------------------------
test('Eine unerreichbare Anbindung kostet nicht die übrigen', function () use ($quelle) {
$src = $quelle('src/Directory/DirectorySyncRunner.php');
$start = strpos($src, 'foreach ($anbindungen as $anbindung)');
$body = substr($src, (int)$start, 1200);
assertTrue(str_contains($body, 'catch (\\Throwable $e)'), 'Ein Fehler bricht den ganzen Lauf ab');
assertTrue(str_contains($body, '$geglueckt'), 'Es wird nicht gezählt, was durchkam');
// Gescheitert ist der Lauf nur, wenn keine einzige Anbindung durchkam.
assertTrue(str_contains($src, "'ok' => \$geglueckt > 0,"), 'Ein einzelner Fehlschlag gilt als Gesamtfehler');
});
test('Jeder Lauf steht an seiner Anbindung und im Protokoll', function () use ($quelle) {
$src = $quelle('src/Directory/DirectorySyncRunner.php');
assertTrue(str_contains($src, '$manager->recordRun($id, $ok, $meldung);'), 'Das Ergebnis bleibt nicht an der Anbindung');
assertTrue(str_contains($src, "'directory_sync_failed'"), 'Ein Fehlschlag wird nicht protokolliert');
});
// ----------------------------------------------------------------
// Entfernen
// ----------------------------------------------------------------
test('Das Entfernen einer Anbindung löscht keine Personen', function () use ($quelle) {
// Sie hängen an Geräten, Tickets und Anträgen; ein Löschen risse dort
// Lücken, die niemand mehr füllen kann.
$src = $quelle('src/Directory/DirectoryConnectionManager.php');
$start = strpos($src, 'public function delete(int $id): int');
$body = substr($src, (int)$start, 700);
assertTrue(str_contains($body, 'SET is_active = 0, connection_id = NULL'), 'Die Personen werden nicht stillgelegt');
assertFalse(str_contains($body, "delete('directory_persons'"), 'Die Personen werden gelöscht');
assertTrue(str_contains($body, "delete('directory_connections', 'id = ?'"), 'Die Anbindung bleibt stehen');
});
// ----------------------------------------------------------------
// Ablage
// ----------------------------------------------------------------
test('Der Schlüssel folgt der Anbindung, nicht der Art', function () use ($quelle) {
$sql = $quelle('database/migrations/223_directory_connections.sql');
assertTrue(str_contains($sql, 'CREATE TABLE IF NOT EXISTS `directory_connections`'), 'Die Tabelle fehlt');
assertTrue(str_contains($sql, 'ADD COLUMN `connection_id`'), 'Die Herkunft fehlt an der Person');
assertTrue(str_contains($sql, 'DROP INDEX `uq_person_external`'), 'Der alte Schlüssel bleibt stehen');
assertTrue(
str_contains($sql, 'ADD UNIQUE KEY `uq_person_connection` (`connection_id`, `external_id`(180))'),
'Der neue Schlüssel fehlt'
);
// Vorhandene Personen bekommen ihre Herkunft, sonst stünde der neue
// Schlüssel über lauter NULL und schützte nichts.
assertTrue(str_contains($sql, 'SET p.`connection_id` = c.`id`'), 'Bestehende Personen bleiben ohne Herkunft');
assertTrue(str_contains($sql, 'WHERE p.`connection_id` IS NULL'), 'Die Zuordnung ist nicht wiederholbar');
});
test('Die Anbindungen sind im Backend erreichbar und gesichert', function () use ($quelle) {
$app = $quelle('src/Core/Application.php');
foreach (['connectionForm', 'connectionSave', 'connectionSync', 'connectionDelete'] as $aktion) {
assertTrue(str_contains($app, 'DirectoryAdminController@' . $aktion), 'Route fehlt: ' . $aktion);
}
// Die feste Strecke muss vor der {id}-Strecke stehen.
$neu = strpos($app, "/admin/directory/connections/new");
$id = strpos($app, "/admin/directory/connections/{id}/edit");
assertTrue($neu !== false && $id !== false && $neu < $id, 'Die {id}-Route fängt „new" ab');
foreach (['connections.php', 'connection_form.php'] as $datei) {
$tpl = $quelle('templates/admin/directory/' . $datei);
assertTrue(str_contains($tpl, 'name="_csrf"'), $datei . ': Ohne Token schlägt das Absenden fehl');
}
});
};