Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\ApiAuto\ApiAutoMapper;
use OpenNIT\ApiAuto\ApiAutoRunner;
use OpenNIT\ApiAuto\ImportTargetInterface;
use OpenNIT\ApiAuto\ImportTargetRegistry;
use OpenNIT\ApiAuto\ValueTransformer;
use OpenNIT\Core\Database;
/**
* Haelt die Zusagen des Moduls „API-Automatisierung" fest.
*
* Zwei Dinge tragen die Stabilitaetszusage: die Feldumwandlung, die aus fremden
* Daten OpenNIT-Werte macht, und die Absicherung, die verhindert, dass eine
* fremde API mehr schreibt als vorgesehen.
*/
return static function (): void {
$wurzel = dirname(__DIR__);
$src = static fn (string $datei): string => (string)file_get_contents($wurzel . $datei);
/** Kurzform: einen Wert nach Vorschrift umwandeln. */
$um = static fn (array $satz, array|string $spec): mixed => ValueTransformer::extract($satz, $spec);
// ----------------------------------------------------------------
// Datum und Zeit
// ----------------------------------------------------------------
test('Ein kompaktes Datum bleibt ein Datum', function () use ($um) {
// YYYYMMDD ist ebenfalls numerisch, meint aber kein Epoch-Sekunden.
assertSame('2026-09-15', $um(['d' => '20260915'], ['source' => 'd', 'transform' => 'date']));
assertSame('2026-09-15', $um(['d' => 20260915], ['source' => 'd', 'transform' => 'date']));
});
test('Ein Epoch-Wert bleibt ein Epoch-Wert', function () use ($um) {
// 1789456000 = 2026-09-15; als Text ist das fuer strtotime kein Datum.
assertSame('2026-09-15', $um(['d' => 1789456000], ['source' => 'd', 'transform' => 'date']));
assertSame('2026-09-15', $um(['d' => '1789456000'], ['source' => 'd', 'transform' => 'date']));
});
test('Uebliche Textformate werden erkannt', function () use ($um) {
assertSame('2026-09-15', $um(['d' => '2026-09-15'], ['source' => 'd', 'transform' => 'date']));
assertSame('2026-09-15 10:15:00', $um(['d' => '2026-09-15T10:15:00Z'], ['source' => 'd', 'transform' => 'datetime']));
assertSame('2026-09-15 10:15:00', $um(['d' => '20260915T101500Z'], ['source' => 'd', 'transform' => 'datetime']));
});
test('Unlesbare und fehlende Datumsangaben werden nicht geraten', function () use ($um) {
assertSame(null, $um(['d' => 'kein datum'], ['source' => 'd', 'transform' => 'date']));
assertSame(null, $um(['d' => ''], ['source' => 'd', 'transform' => 'date']));
assertSame(null, $um([], ['source' => 'd', 'transform' => 'date']));
});
// ----------------------------------------------------------------
// Fehlende Werte
// ----------------------------------------------------------------
test('Ein fehlendes Feld bleibt bei jeder Umwandlung unbestimmt', function () use ($um) {
// Sonst entstuende aus „nicht geliefert" ein echter Wert - etwa ein
// „nein", das die fremde API nie gesagt hat.
foreach (['bool', 'int', 'number', 'trim', 'lower', 'upper', 'date', 'datetime'] as $t) {
assertSame(null, $um([], ['source' => 'fehlt', 'transform' => $t]), "Umwandlung {$t}");
}
});
test('Ein leerer Text zaehlt bei Zahl und Datum als unbestimmt', function () use ($um) {
foreach (['bool', 'int', 'number', 'date', 'datetime'] as $t) {
assertSame(null, $um(['x' => ''], ['source' => 'x', 'transform' => $t]), "Umwandlung {$t}");
}
});
test('Ein leerer Text bleibt bei Text-Umwandlungen ein leerer Text', function () use ($um) {
// „Leer" ist bei einem Textfeld eine Angabe, kein fehlender Wert.
foreach (['trim', 'lower', 'upper'] as $t) {
assertSame('', $um(['x' => ''], ['source' => 'x', 'transform' => $t]), "Umwandlung {$t}");
}
});
test('Ein geliefertes Nein bleibt ein Nein', function () use ($um) {
assertSame(0, $um(['x' => false], ['source' => 'x', 'transform' => 'bool']));
assertSame(0, $um(['x' => 'nein'], ['source' => 'x', 'transform' => 'bool']));
assertSame(0, $um(['x' => '0'], ['source' => 'x', 'transform' => 'bool']));
assertSame(1, $um(['x' => true], ['source' => 'x', 'transform' => 'bool']));
assertSame(1, $um(['x' => 'yes'], ['source' => 'x', 'transform' => 'bool']));
assertSame(1, $um(['x' => 'aktiv'] , ['source' => 'x', 'transform' => 'map', 'values' => ['aktiv' => 1]]));
});
// ----------------------------------------------------------------
// Uebrige Umwandlungen
// ----------------------------------------------------------------
test('Zusammensetzen laesst Luecken aus', function () use ($um) {
// Sonst stuenden fuehrende oder doppelte Trennzeichen im Ergebnis.
$spec = ['transform' => 'concat', 'sources' => ['vor', 'mittel', 'nach'], 'sep' => ' '];
assertSame('Anna Musterfrau', $um(['vor' => 'Anna', 'mittel' => '', 'nach' => 'Musterfrau'], $spec));
assertSame('Anna', $um(['vor' => 'Anna'], $spec));
});
test('Zerlegen liefert den gewuenschten Teil', function () use ($um) {
$satz = ['n' => 'Anna Maria Musterfrau'];
assertSame('Anna', $um($satz, ['source' => 'n', 'transform' => 'split', 'part' => 'first']));
assertSame('Musterfrau', $um($satz, ['source' => 'n', 'transform' => 'split', 'part' => 'last']));
assertSame('Maria', $um($satz, ['source' => 'n', 'transform' => 'split', 'part' => 1]));
assertSame(null, $um(['n' => ''], ['source' => 'n', 'transform' => 'split']));
});
test('Eine Werteliste greift, sonst gilt der Ausweichwert', function () use ($um) {
$spec = ['source' => 's', 'transform' => 'map', 'values' => ['A' => 'aktiv'], 'default' => 'unbekannt'];
assertSame('aktiv', $um(['s' => 'A'], $spec));
assertSame('unbekannt', $um(['s' => 'Z'], $spec));
// Ohne Ausweichwert bleibt der fremde Wert stehen - wer das nicht will,
// traegt einen 'default' ein.
$ohne = ['source' => 's', 'transform' => 'map', 'values' => ['A' => 'aktiv']];
assertSame('Z', $um(['s' => 'Z'], $ohne));
});
test('Ein fester Wert braucht keine Quelle', function () use ($um) {
assertSame('Import', $um([], ['transform' => 'const', 'value' => 'Import']));
});
test('Verschachtelte Felder werden ueber den Punktpfad gelesen', function () use ($um) {
$satz = ['a' => ['b' => ['c' => 'Treffer']]];
assertSame('Treffer', $um($satz, 'a.b.c'));
assertSame(null, $um($satz, 'a.b.weg'));
assertSame(null, $um($satz, 'a.weg.c'));
});
// ----------------------------------------------------------------
// Grenzen der Umwandlung
// ----------------------------------------------------------------
test('Es gibt nur die deklarierten Umwandlungen', function () use ($src) {
// Die Liste ist die Zusage an die Oberflaeche und an den KI-Vorschlag;
// laeuft sie auseinander, entstehen Vorschlaege, die nichts tun.
assertSame(
['none', 'date', 'datetime', 'split', 'map', 'lower', 'upper',
'trim', 'bool', 'int', 'number', 'const', 'concat'],
ValueTransformer::TRANSFORMS
);
$code = $src('/src/ApiAuto/ValueTransformer.php');
foreach (ValueTransformer::TRANSFORMS as $t) {
if ($t === 'none') {
continue;
}
assertTrue(
str_contains($code, "case '{$t}':") || str_contains($code, "\$transform === '{$t}'"),
"Umwandlung '{$t}' ist angekuendigt, aber nicht umgesetzt"
);
}
});
test('Eine unbekannte Umwandlung veraendert nichts', function () use ($um) {
// Kein Ausfall und kein stiller Datenverlust, falls ein Mapping aus einer
// aelteren Fassung eine Umwandlung nennt, die es nicht mehr gibt.
assertSame('unveraendert', $um(['x' => 'unveraendert'], ['source' => 'x', 'transform' => 'gibtesnicht']));
});
test('Zugangsdaten-Umwandlungen rechnen nachvollziehbar', function () {
assertSame(base64_encode(hash('sha256', 'geheim', true)), ValueTransformer::credential('geheim', 'sha256_base64'));
assertSame(md5('geheim'), ValueTransformer::credential('geheim', 'md5_hex'));
assertSame(base64_encode('geheim'), ValueTransformer::credential('geheim', 'base64'));
assertSame(
base64_encode(hash_hmac('sha512', 'geheim', 'salz', true)),
ValueTransformer::credential('geheim', 'hmac_sha512_base64', 'salz')
);
// Unbekanntes Verfahren gibt den Klartext zurueck, statt etwas zu erfinden.
assertSame('geheim', ValueTransformer::credential('geheim', 'gibtesnicht'));
});
// ----------------------------------------------------------------
// Handkorrekturen schuetzen
// ----------------------------------------------------------------
/** Baut einen Runner mit vorgegebenem Bestandsdatensatz. */
$runnerMit = static function (?array $bestand): ApiAutoRunner {
$db = new class ($bestand) extends Database {
public function __construct(private ?array $bestand) {}
public function fetchOne(string $sql, array $params = []): ?array
{
return $this->bestand;
}
};
$klasse = new ReflectionClass(ApiAutoRunner::class);
$runner = $klasse->newInstanceWithoutConstructor();
$feld = $klasse->getProperty('db');
$feld->setAccessible(true);
$feld->setValue($runner, $db);
return $runner;
};
/** Ruft die geschuetzte Methode auf. */
$wahre = static function (ApiAutoRunner $runner, array $rec, array $schutz): array {
$m = new ReflectionMethod(ApiAutoRunner::class, 'wahreHandkorrekturen');
$m->setAccessible(true);
return (array)$m->invoke($runner, (new ImportTargetRegistry())->get('contacts'), $rec, $schutz, 'email');
};
test('Geschuetzte Felder liest der Runner aus dem Mapping', function () {
assertSame([], ApiAutoRunner::geschuetzteFelder(['field_map' => ['a' => 'x']]));
assertSame([], ApiAutoRunner::geschuetzteFelder([]));
assertSame(
['phone'],
ApiAutoRunner::geschuetzteFelder(['field_map' => [
'phone' => ['source' => 'p', 'write' => 'if_empty'],
'email' => ['source' => 'e'],
'notes' => ['source' => 'n', 'write' => 'always'],
]])
);
});
test('Ein vorhandener Wert ueberlebt den naechsten Lauf', function () use ($runnerMit, $wahre) {
// Der Kern: wer eine Telefonnummer von Hand korrigiert, soll sie
// behalten. Die Ziele aktualisieren nur uebergebene Felder.
$ergebnis = $wahre(
$runnerMit(['email' => 'a@b.c', 'phone' => '030 999']),
['email' => 'a@b.c', 'phone' => 'AUS-DER-API', 'notes' => 'immer'],
['phone']
);
assertFalse(array_key_exists('phone', $ergebnis), 'Die Handkorrektur wird ueberschrieben');
assertSame('immer', $ergebnis['notes'], 'Ungeschuetztes Feld faellt weg');
assertSame('a@b.c', $ergebnis['email'], 'Der Schluessel faellt weg');
});
test('Ein leeres Feld wird befuellt', function () use ($runnerMit, $wahre) {
foreach ([null, '', ' '] as $leer) {
$ergebnis = $wahre(
$runnerMit(['email' => 'a@b.c', 'phone' => $leer]),
['email' => 'a@b.c', 'phone' => 'AUS-DER-API'],
['phone']
);
assertSame('AUS-DER-API', $ergebnis['phone'] ?? null, 'Leeres Feld bleibt leer');
}
});
test('Ein neuer Datensatz wird vollstaendig angelegt', function () use ($runnerMit, $wahre) {
// Ohne Bestand gibt es nichts zu wahren.
$ergebnis = $wahre(
$runnerMit(null),
['email' => 'neu@b.c', 'phone' => 'AUS-DER-API'],
['phone']
);
assertSame('AUS-DER-API', $ergebnis['phone'] ?? null);
});
test('Ohne Schutzfelder wird nicht einmal nachgesehen', function () use ($wahre) {
// Kein Schutz, keine zusaetzliche Abfrage je Datensatz.
$db = new class extends Database {
public int $abfragen = 0;
public function __construct() {}
public function fetchOne(string $sql, array $params = []): ?array
{
$this->abfragen++;
return null;
}
};
$klasse = new ReflectionClass(ApiAutoRunner::class);
$runner = $klasse->newInstanceWithoutConstructor();
$feld = $klasse->getProperty('db');
$feld->setAccessible(true);
$feld->setValue($runner, $db);
$rec = ['email' => 'a@b.c', 'phone' => 'x'];
assertSame($rec, $wahre($runner, $rec, []));
assertSame(0, $db->abfragen, 'Es wird ohne Anlass gelesen');
});
// ----------------------------------------------------------------
// Mapping von Hand
// ----------------------------------------------------------------
test('Ein Mapping laesst sich ohne KI pruefen und speichern', function () use ($src) {
// Ohne diesen Weg braucht jede neue Verbindung einen KI-Schluessel.
$ctrl = $src('/src/Controllers/Admin/ApiAutoController.php');
assertTrue(str_contains($ctrl, 'function saveMappingManual'), 'Keine Aktion zum Speichern');
assertTrue(str_contains($ctrl, 'ApiAutoMapper::pruefeMapping('), 'Ungeprueft gespeichert');
assertTrue(str_contains($ctrl, "can('create_api_auto')"), 'Keine Rechtepruefung');
$app = $src('/src/Core/Application.php');
assertTrue(str_contains($app, "'/admin/api-auto/{id}/mapping-manual'"), 'Route fehlt');
});
test('Die Whitelist gilt auch fuer ein handgeschriebenes Mapping', function () {
$ziel = (new ImportTargetRegistry())->get('contacts');
$roh = ['request' => ['path' => '/p'], 'field_map' => [
'email' => 'mail',
'gehalt' => 'salary',
]];
$ergebnis = ApiAutoMapper::pruefeMapping($roh, $ziel);
assertSame(['email'], array_keys($ergebnis['mapping']['field_map']), 'Fremdes Feld durchgelassen');
// Verworfenes wird benannt, statt stillschweigend zu verschwinden.
assertSame(1, count($ergebnis['hinweise']));
assertTrue(str_contains($ergebnis['hinweise'][0], 'gehalt'));
});
test('Ein Mapping ohne gueltige Zuordnung wird abgewiesen', function () {
$ziel = (new ImportTargetRegistry())->get('contacts');
$geworfen = false;
try {
ApiAutoMapper::pruefeMapping(['field_map' => ['gibtesnicht' => 'x']], $ziel);
} catch (\InvalidArgumentException $e) {
$geworfen = true;
// Die Meldung nennt die erlaubten Felder, sonst raet der Nutzer.
assertTrue(str_contains($e->getMessage(), 'email'), 'Meldung nennt die Felder nicht');
}
assertTrue($geworfen, 'Leeres Mapping wurde angenommen');
});
test('Die Schreibregel ueberlebt die Pruefung', function () {
$ziel = (new ImportTargetRegistry())->get('contacts');
$ergebnis = ApiAutoMapper::pruefeMapping(['field_map' => [
'email' => 'mail',
'phone' => ['source' => 'tel', 'write' => 'if_empty'],
'notes' => ['source' => 'n', 'write' => 'always'],
]], $ziel);
$map = $ergebnis['mapping']['field_map'];
assertSame('if_empty', $map['phone']['write'] ?? null, 'Der Schutz geht verloren');
// 'always' ist der Standard und wird nicht mitgeschleppt.
assertFalse(isset($map['notes']['write']), 'Standardwert wird gespeichert');
});
test('Unsinnige Angaben werden zurechtgerueckt, nicht uebernommen', function () {
$ziel = (new ImportTargetRegistry())->get('contacts');
$ergebnis = ApiAutoMapper::pruefeMapping([
'request' => ['method' => 'DELETE', 'path' => '/p'],
'pagination' => ['type' => 'quatsch', 'size' => 9999],
'field_map' => ['email' => 'mail'],
'dedup' => ['email', 'gehalt'],
], $ziel);
$m = $ergebnis['mapping'];
assertSame('GET', $m['request']['method'], 'Import schreibt per DELETE');
assertSame('none', $m['pagination']['type'], 'Unbekanntes Blaettern uebernommen');
assertSame(500, $m['pagination']['size'], 'Obergrenze nicht gekappt');
assertSame(['email'], $m['dedup'], 'Zusammenfuehren ueber ein nicht zugeordnetes Feld');
});
test('Ein gespeichertes Mapping verlangt eine neue Vorschau', function () use ($src) {
// Sonst liefe eine geaenderte Zuordnung ungeprueft nach Zeitplan.
$mgr = $src('/src/ApiAuto/ApiAutoManager.php');
$von = strpos($mgr, 'public function saveMapping');
assertTrue(str_contains(substr($mgr, (int)$von, 400), "'preview_ok' => 0"), 'Vorschau wird nicht zurueckgesetzt');
});
// ----------------------------------------------------------------
// Wirkungsvorschau
// ----------------------------------------------------------------
/** Ruft die Wirkungsanalyse gegen einen vorgegebenen Bestand auf. */
$wirkung = static function (array $bestand, array $records, array $mapping): array {
$db = new class ($bestand) extends Database {
public function __construct(private array $bestand) {}
public function fetchOne(string $sql, array $params = []): ?array
{
return $this->bestand[$params[0] ?? ''] ?? null;
}
};
$klasse = new ReflectionClass(ApiAutoRunner::class);
$runner = $klasse->newInstanceWithoutConstructor();
$feld = $klasse->getProperty('db');
$feld->setAccessible(true);
$feld->setValue($runner, $db);
$m = new ReflectionMethod(ApiAutoRunner::class, 'wirkung');
$m->setAccessible(true);
return (array)$m->invoke($runner, (new ImportTargetRegistry())->get('contacts'), $records, $mapping);
};
test('Die Vorschau zaehlt neu, geaendert und unveraendert', function () use ($wirkung) {
// Vor dem Scharfschalten ist nicht die geholte Zeile interessant,
// sondern was sie im Bestand anrichtet.
$w = $wirkung(
[
'a@b.c' => ['email' => 'a@b.c', 'phone' => '030 111', 'first_name' => 'Anna'],
'c@d.e' => ['email' => 'c@d.e', 'phone' => '030 222', 'first_name' => 'Carl'],
],
[
['email' => 'a@b.c', 'phone' => '030 999', 'first_name' => 'Anna'],
['email' => 'c@d.e', 'phone' => '030 222', 'first_name' => 'Carl'],
['email' => 'neu@x.y', 'phone' => '1', 'first_name' => 'Neu'],
],
['field_map' => ['email' => 'e', 'phone' => 'p', 'first_name' => 'f']]
);
assertSame(1, $w['neu']);
assertSame(1, $w['geaendert']);
assertSame(1, $w['unveraendert']);
assertSame(3, $w['geprueft']);
assertTrue($w['vollstaendig']);
});
test('Die Vorschau benennt Feld, Vorher und Nachher', function () use ($wirkung) {
$w = $wirkung(
['a@b.c' => ['email' => 'a@b.c', 'phone' => '030 111']],
[['email' => 'a@b.c', 'phone' => '030 999']],
['field_map' => ['email' => 'e', 'phone' => 'p']]
);
assertSame(1, count($w['aenderungen']));
assertSame('a@b.c', $w['aenderungen'][0]['schluessel']);
assertSame(
[['feld' => 'phone', 'vorher' => '030 111', 'nachher' => '030 999']],
$w['aenderungen'][0]['felder']
);
});
test('Die Vorschau zeigt, was der Feldschutz verhindert', function () use ($wirkung) {
// Ein geschuetztes Feld wuerde nicht geschrieben - also darf es auch
// nicht als Aenderung angekuendigt werden.
$bestand = ['a@b.c' => ['email' => 'a@b.c', 'phone' => '030 111']];
$records = [['email' => 'a@b.c', 'phone' => '030 999']];
$ohne = $wirkung($bestand, $records, ['field_map' => ['email' => 'e', 'phone' => 'p']]);
assertSame(1, $ohne['geaendert'], 'Ohne Schutz fehlt die Aenderung');
$mit = $wirkung($bestand, $records, ['field_map' => [
'email' => 'e',
'phone' => ['source' => 'p', 'write' => 'if_empty'],
]]);
assertSame(0, $mit['geaendert'], 'Geschuetztes Feld als Aenderung angekuendigt');
assertSame(1, $mit['unveraendert']);
});
test('Gleiche Werte in anderer Schreibweise sind keine Aenderung', function () use ($wirkung) {
// Die Datenbank liefert Text, die Quelle auch Zahlen und Wahrheitswerte.
$w = $wirkung(
['a@b.c' => ['email' => 'a@b.c', 'phone' => '49', 'notes' => null]],
[['email' => 'a@b.c', 'phone' => 49, 'notes' => '']],
['field_map' => ['email' => 'e', 'phone' => 'p', 'notes' => 'n']]
);
assertSame(0, $w['geaendert'], 'Schreibweise als Aenderung gewertet');
assertSame(1, $w['unveraendert']);
});
test('Die Vorschau prueft nicht unbegrenzt viele Datensaetze', function () use ($wirkung) {
// Je Datensatz eine Abfrage - bei zehntausenden waere die Vorschau
// teurer als der Lauf.
$records = [];
for ($i = 0; $i < 600; $i++) {
$records[] = ['email' => "p{$i}@x.y", 'phone' => '1'];
}
$w = $wirkung([], $records, ['field_map' => ['email' => 'e', 'phone' => 'p']]);
assertSame(500, $w['geprueft']);
assertFalse($w['vollstaendig'], 'Die Begrenzung wird nicht ausgewiesen');
});
test('Die Vorschau schreibt nichts', function () use ($src) {
$code = $src('/src/ApiAuto/ApiAutoRunner.php');
$von = strpos($code, 'private function wirkung');
$block = substr($code, (int)$von, 3000);
foreach (['->update(', '->insert(', 'DELETE', 'INSERT', 'UPDATE '] as $wort) {
assertFalse(str_contains($block, $wort), "Schreibender Zugriff in der Vorschau: {$wort}");
}
});
// ----------------------------------------------------------------
// Loeschbehandlung
// ----------------------------------------------------------------
test('Die Loeschbehandlung wird nur angeboten, wo sie wirkt', function () use ($src) {
// Kontakte, Firmen und SSL-Eintraege fuehren keinen Status, ueber den
// sich "nicht mehr vorhanden" abbilden liesse.
$registry = new ImportTargetRegistry();
foreach (['contacts', 'companies', 'ssl'] as $key) {
$ziel = $registry->get($key);
if ($ziel === null) {
continue;
}
assertFalse(
$ziel instanceof \OpenNIT\ApiAuto\SupportsDeactivation,
"{$key} behauptet, deaktivieren zu koennen"
);
}
// Der Schalter steht nicht ungeprueft im Formular …
$tpl = $src('/templates/api-auto/show.php');
assertTrue(str_contains($tpl, '$kannDeaktivieren'), 'Der Schalter ist ungegated');
assertTrue(str_contains($tpl, 'SupportsDeactivation'), 'Es wird nicht auf die Faehigkeit geprueft');
// … und wird auch serverseitig nicht gespeichert.
$ctrl = $src('/src/Controllers/Admin/ApiAutoController.php');
assertTrue(
str_contains($ctrl, 'instanceof \OpenNIT\ApiAuto\SupportsDeactivation'),
'Der Schalter laesst sich am Formular vorbei setzen'
);
});
test('Wer deaktivieren kann, tut es nur bei eigenen Importen', function () use ($src) {
// Sonst raeumte ein Import von Hand gepflegte Datensaetze ab.
$runner = $src('/src/ApiAuto/ApiAutoRunner.php');
assertTrue(str_contains($runner, 'instanceof SupportsDeactivation'), 'Faehigkeit ungeprueft');
assertTrue(str_contains($runner, 'api_auto_imported_records'), 'Kein Bezug auf eigene Importe');
});
// ----------------------------------------------------------------
// Absicherung: was darf ueberhaupt geschrieben werden
// ----------------------------------------------------------------
test('Die Feld-Whitelist greift beim Lauf, nicht nur beim Einrichten', function () use ($src) {
// Sonst koennte ein veraendertes oder veraltetes Mapping Felder
// befuellen, die das Ziel nie freigegeben hat.
$code = $src('/src/ApiAuto/ApiAutoRunner.php');
assertTrue(str_contains($code, '$allowed = $target->writableFields();'), 'Whitelist wird nicht geladen');
assertTrue(str_contains($code, 'if (!isset($allowed[$targetField])) {'), 'Whitelist wird nicht angewendet');
assertTrue(str_contains($code, 'if (is_scalar($value) || $value === null) {'), 'Nicht-Skalare werden durchgelassen');
});
test('Automatisch laeuft nur, was ein Mensch freigegeben hat', function () use ($src) {
$code = $src('/src/ApiAuto/ApiAutoManager.php');
$von = strpos($code, 'public function dueConnections');
$block = substr($code, (int)$von, 600);
foreach (["enabled = 1", "status = 'ready'", "preview_ok = 1"] as $bedingung) {
assertTrue(str_contains($block, $bedingung), "Freigabe-Bedingung fehlt: {$bedingung}");
}
// Neu angelegte Verbindungen sind Entwuerfe.
assertTrue(str_contains($code, "'status' => 'draft'"), 'Neue Verbindung ist nicht Entwurf');
});
test('Im laufenden Betrieb ist keine KI beteiligt', function () use ($src) {
// Die KI hilft beim Einrichten; ein Zeitplan-Lauf muss vorhersagbar sein.
$handler = $src('/src/Cron/Handlers/ApiAutoDispatchHandler.php');
assertFalse(str_contains($handler, 'ApiAutoMapper'), 'Der Zeitplan-Lauf zieht die KI hinzu');
assertFalse(str_contains($handler, 'AIProvider'), 'Der Zeitplan-Lauf zieht die KI hinzu');
$runner = $src('/src/ApiAuto/ApiAutoRunner.php');
assertFalse(str_contains($runner, 'ApiAutoMapper'), 'Der Lauf selbst zieht die KI hinzu');
assertFalse(str_contains($runner, 'AIProvider'), 'Der Lauf selbst zieht die KI hinzu');
});
test('Der Lauf haelt sich an die SSRF-Absicherung', function () use ($src) {
$code = $src('/src/ApiAuto/ApiAutoRunner.php');
assertTrue(str_contains($code, 'SsrfGuard::isLinkLocalOrMetadata'), 'Metadaten-Dienste nicht gesperrt');
assertTrue(str_contains($code, 'SsrfGuard::isRequestUrlAllowed'), 'Allowlist wird nicht geprueft');
// Weiterleitungen duerfen die Pruefung nicht umgehen.
assertFalse(str_contains($code, 'CURLOPT_FOLLOWLOCATION => true'), 'Weiterleitungen laufen ungeprueft');
assertTrue(str_contains($code, '$this->assertUrlAllowed($redirect, $allowlist);'), 'Weiterleitungsziel ungeprueft');
});
test('Zugangsdaten stehen in keiner Fehlermeldung', function () {
// Bei einem Schluessel im Query-Teil wuerde eine ungekuerzte URL ihn
// in Fehlermeldungen und Lauf-Protokolle tragen.
$m = new ReflectionMethod(ApiAutoRunner::class, 'redactUrl');
$m->setAccessible(true);
$runner = (new ReflectionClass(ApiAutoRunner::class))->newInstanceWithoutConstructor();
$gekuerzt = (string)$m->invoke($runner, 'https://nutzer:passwort@api.example.com:8443/v1/items?api_key=GEHEIM&x=1');
assertSame('https://api.example.com:8443/v1/items', $gekuerzt);
assertFalse(str_contains($gekuerzt, 'GEHEIM'), 'Schluessel steht in der Meldung');
assertFalse(str_contains($gekuerzt, 'passwort'), 'Passwort steht in der Meldung');
assertSame('[URL]', (string)$m->invoke($runner, 'kein-url-text'));
});
test('Das Audit-Protokoll bekommt keine Zugangsdaten', function () use ($src) {
$code = $src('/src/ApiAuto/ApiAutoRunner.php');
assertTrue(str_contains($code, "['sent' => \$sent, 'errors' => count(\$errors)]"), 'Audit-Inhalt veraendert');
// Kein Audit-Aufruf reicht das Geheimnis oder die volle Verbindung durch.
foreach (["auth_secret", "\$secret", "\$token"] as $begriff) {
assertFalse(
(bool)preg_match('/audit->log\w*\([^;]{0,400}' . preg_quote($begriff, '/') . '/s', $code),
"Audit-Aufruf fuehrt {$begriff} mit"
);
}
});
// ----------------------------------------------------------------
// Ziele
// ----------------------------------------------------------------
test('Jedes Ziel ist vollstaendig beschrieben', function () {
$ziele = (new ImportTargetRegistry())->all();
assertSame(10, count($ziele), 'Anzahl der Ziele hat sich geaendert');
foreach ($ziele as $key => $ziel) {
assertTrue($ziel instanceof ImportTargetInterface, "{$key}: falscher Typ");
assertTrue($key !== '', 'Ziel ohne Schluessel');
assertTrue($ziel->label() !== '', "{$key}: kein Anzeigename");
assertTrue($ziel->description() !== '', "{$key}: keine Beschreibung fuer den KI-Vorschlag");
assertTrue($ziel->tableName() !== '', "{$key}: keine Tabelle");
$felder = $ziel->writableFields();
assertTrue($felder !== [], "{$key}: leere Feld-Whitelist");
// Ohne Dedup-Feld wuerde jeder Lauf Dubletten anlegen.
$dedup = $ziel->dedupFields();
assertTrue($dedup !== [], "{$key}: kein Feld zum Zusammenfuehren");
foreach ($dedup as $f) {
assertTrue(isset($felder[$f]), "{$key}: Dedup-Feld '{$f}' steht nicht in der Whitelist");
}
foreach ($ziel->virtualFields() as $f) {
assertTrue(isset($felder[$f]), "{$key}: virtuelles Feld '{$f}' steht nicht in der Whitelist");
}
}
});
test('Ein Ziel erscheint nur mit seinem Modul', function () {
$registry = new ImportTargetRegistry();
// Ohne aktive Module bleiben nur modulfreie Ziele uebrig.
foreach ($registry->available([]) as $key => $ziel) {
assertSame('', $ziel->requiredModule(), "{$key}: erscheint ohne sein Modul");
}
// Ein unbekannter Schluessel liefert nichts, statt zu raten.
assertSame(null, $registry->get('gibtesnicht'));
assertSame(null, $registry->get(''));
});
// ----------------------------------------------------------------
// Reifegrad
// ----------------------------------------------------------------
test('Die Kennzeichnung „experimentell" ist an allen drei Stellen weg', function () use ($src) {
$stellen = [
'/templates/admin/settings/index.php',
'/templates/admin/manual/index.php',
'/templates/admin/manual/pdf.php',
];
foreach ($stellen as $datei) {
$code = $src($datei);
assertFalse(str_contains($code, 'API-Auto (experimentell)'), "Kennzeichnung bleibt in {$datei}");
assertFalse(
(bool)preg_match('/API-Auto\s*<span class="badge badge-warning">/', $code),
"Abzeichen bleibt in {$datei}"
);
}
// Der Opt-in-Charakter bleibt bestehen - das Modul bindet fremde Systeme an.
assertTrue(str_contains($src('/public/index.php'), "'api_auto' => false,"), 'Modul ist nicht mehr Opt-in');
});
test('Auch die Modulseiten selbst tragen keinen Vorbehalt mehr', function () use ($wurzel) {
// Die drei Pflichtstellen sind nicht die einzigen Orte, an denen ein
// Reifegrad stehen kann.
$dateien = array_merge(
glob($wurzel . '/templates/api-auto/*.php') ?: [],
glob($wurzel . '/templates/admin/api-auto/*.php') ?: []
);
assertTrue($dateien !== [], 'Keine Modul-Templates gefunden');
foreach ($dateien as $pfad) {
$inhalt = (string)file_get_contents($pfad);
$name = basename($pfad);
assertFalse((bool)preg_match('/[Ee]xperimentell/', $inhalt), "Vorbehalt in {$name}");
assertFalse((bool)preg_match('/Vorschau-Feature/', $inhalt), "Vorbehalt in {$name}");
}
});
test('Nirgends im Auslieferungsstand steht noch ein Vorbehalt', function () use ($wurzel) {
// Der Reifegrad stand an mehr Stellen als den drei dokumentierten -
// Modulseiten, Lizenz-Dialog, README, NOTICE. Deshalb wird hier der
// gesamte ausgelieferte Stand geprueft statt einzelner Dateien.
$suche = static function (string $verzeichnis, string $muster) use ($wurzel): array {
$treffer = [];
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($wurzel . $verzeichnis));
foreach ($it as $datei) {
if (!$datei->isFile() || !preg_match($muster, $datei->getFilename())) {
continue;
}
$treffer[] = $datei->getPathname();
}
return $treffer;
};
$dateien = array_merge(
$suche('/templates', '/\.php$/'),
$suche('/src', '/\.php$/'),
[$wurzel . '/README.md', $wurzel . '/NOTICE.md', $wurzel . '/ROADMAP.md', $wurzel . '/public/index.php']
);
$offen = [];
foreach ($dateien as $pfad) {
foreach (explode("\n", (string)file_get_contents($pfad)) as $nr => $zeile) {
// Nur Zeilen, die beides zugleich nennen - „experimentelle
// KI-Aktionen" des Chat-Moduls sind ein anderes Thema.
if (preg_match('/api[-_ ]?auto/i', $zeile)
&& preg_match('/experimentell|experimental|\(Beta\)|Vorschau-Feature/i', $zeile)) {
$offen[] = str_replace($wurzel, '', $pfad) . ':' . ($nr + 1);
}
}
}
assertSame([], $offen, 'Reifegrad-Vorbehalt noch vorhanden in: ' . implode(', ', $offen));
});
test('Die Vorschau bleibt als Voraussetzung benannt', function () use ($src) {
// Sie ist keine Empfehlung, sondern die Bedingung fuer den Zeitplan -
// das muss auf der Seite stehen, nicht nur im Code.
$seite = $src('/templates/api-auto/index.php');
assertTrue(str_contains($seite, 'Vorschau'), 'Die Vorschau wird nicht erwaehnt');
assertTrue(str_contains($seite, 'Audit-Log'), 'Die Protokollierung wird nicht erwaehnt');
});
test('OpenNIT fuehrt keine Beta- oder Vorschau-Module mehr', function () use ($src) {
// Die Stabilitaetszusage gilt jetzt fuer alle Module; ein neuer
// Vorbehalt muesste bewusst wieder eingetragen werden.
$schalter = $src('/templates/admin/settings/index.php');
foreach (['⚠ Experimentell', '(Beta)', 'badge-warning">Beta'] as $marke) {
assertFalse(str_contains($schalter, $marke), "Reifegrad-Marke bleibt: {$marke}");
}
});
test('Die Ziel-Module sind bekannte Module', function () use ($wurzel) {
// Ein vertippter Modul-Key wuerde das Ziel dauerhaft unsichtbar machen.
$index = (string)file_get_contents($wurzel . '/public/index.php');
foreach ((new ImportTargetRegistry())->all() as $key => $ziel) {
$mod = $ziel->requiredModule();
if ($mod === '') {
continue;
}
assertTrue(
(bool)preg_match("/'" . preg_quote($mod, '/') . "'\s*=>/", $index),
"{$key}: verlangt unbekanntes Modul '{$mod}'"
);
}
});
};