Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Controllers\WorkflowController;
use OpenNIT\Workflows\WorkflowManager;
/**
* Deckt zwei Stellen ab, an denen das Workflow-Modul bisher stumm blieb:
* fehlgeschlagene Datei-Uploads und die Rohwerte der Datenbank im PDF-Nachweis.
*/
return static function (): void {
$wurzel = dirname(__DIR__);
/** Der Quelltext des Frontend-Controllers. */
$controller = static fn (): string => (string)file_get_contents(
$wurzel . '/src/Controllers/WorkflowController.php'
);
/**
* Ein benannter Methodenrumpf bis zur schließenden Klammer. Der Name wird
* bis zur Klammer verglichen, damit `store` nicht `storeUpload` trifft.
*/
$rumpf = static function (string $name) use ($controller): string {
$src = $controller();
if (!preg_match('/(?:private|public|protected) function ' . preg_quote($name, '/') . '\(/', $src, $t, PREG_OFFSET_CAPTURE)) {
return '';
}
$von = (int)$t[0][1];
$ende = strpos($src, "\n }\n", $von);
return $ende !== false ? substr($src, $von, $ende - $von) : substr($src, $von);
};
// ----------------------------------------------------------------
// Kein stummer Fehlschlag
// ----------------------------------------------------------------
test('Jeder Abbruch nach gewählter Datei nennt seinen Grund', function () use ($rumpf) {
// Ein Anhang, der ohne Hinweis verschwindet, ist schlimmer als einer,
// der gar nicht erst angeboten wird.
$body = $rumpf('storeUpload');
assertTrue($body !== '', 'storeUpload nicht gefunden');
// Der erste Ausstieg ist „nichts ausgewählt" und bleibt ohne Grund;
// jeder weitere muss einen setzen. Signatur und Rücksetzen zählen nicht.
$abbrueche = substr_count($body, 'return null;');
$gruende = preg_match_all('/\$fehler\s*=/', $body)
- preg_match_all('/\$fehler\s*=\s*null/', $body);
assertTrue($abbrueche >= 6, 'Zu wenige Abbruchstellen gefunden – Prüfung greift ins Leere');
assertSame($abbrueche - 1, $gruende, 'Ein Abbruch bleibt ohne Grund');
});
test('Das leere Feld gilt nicht als Fehler', function () use ($rumpf) {
// Wer keine Datei auswählt, soll keine Warnung sehen.
$body = $rumpf('storeUpload');
assertTrue(str_contains($body, 'UPLOAD_ERR_NO_FILE'), 'Der Fall wird nicht unterschieden');
assertTrue(
strpos($body, 'nichts ausgewählt') < strpos($body, '$fehler = $this->uploadFehlertext'),
'Der leere Fall wird nicht zuerst entschieden'
);
});
test('Jeder Upload-Fehlercode von PHP hat einen Satz', function () use ($wurzel) {
$klasse = new ReflectionClass(WorkflowController::class);
$ctrl = $klasse->newInstanceWithoutConstructor();
$methode = $klasse->getMethod('uploadFehlertext');
$methode->setAccessible(true);
$codes = [
\UPLOAD_ERR_INI_SIZE, \UPLOAD_ERR_FORM_SIZE, \UPLOAD_ERR_PARTIAL,
\UPLOAD_ERR_NO_TMP_DIR, \UPLOAD_ERR_CANT_WRITE, \UPLOAD_ERR_EXTENSION,
];
$texte = [];
foreach ($codes as $code) {
$text = (string)$methode->invoke($ctrl, $code);
assertTrue($text !== '', 'Ohne Text: Code ' . $code);
assertFalse(str_contains($text, 'UPLOAD_ERR'), 'Technischer Code in der Meldung: ' . $code);
$texte[] = $text;
}
// Die beiden Größen-Codes meinen dasselbe; die übrigen sollen sich unterscheiden.
assertSame(5, count(array_unique($texte)), 'Verschiedene Ursachen lesen sich gleich');
});
test('Der Grund nennt keine Pfade des Servers', function () use ($wurzel) {
$klasse = new ReflectionClass(WorkflowController::class);
$methode = $klasse->getMethod('uploadFehlertext');
$methode->setAccessible(true);
$ctrl = $klasse->newInstanceWithoutConstructor();
foreach ([\UPLOAD_ERR_NO_TMP_DIR, \UPLOAD_ERR_CANT_WRITE] as $code) {
assertFalse(str_contains((string)$methode->invoke($ctrl, $code), '/'), 'Pfad in der Meldung');
}
});
// ----------------------------------------------------------------
// Der Grund erreicht die Oberfläche
// ----------------------------------------------------------------
test('Ein Kommentar meldet den verlorenen Anhang', function () use ($rumpf) {
$body = $rumpf('comment');
assertTrue(str_contains($body, "storeUpload('attachment', \$uploadFehler)"), 'Der Grund wird nicht abgeholt');
assertTrue(str_contains($body, 'der Anhang jedoch nicht'), 'Der Kommentar meldet den Verlust nicht');
assertTrue(str_contains($body, "'warning'"), 'Der Verlust gilt als Erfolg');
});
test('Ohne Text ist der verlorene Anhang der ganze Fehlschlag', function () use ($rumpf) {
// Sonst meldete das System „Bitte einen Kommentar oder eine Datei angeben" –
// obwohl genau das getan wurde.
$body = $rumpf('comment');
assertTrue(str_contains($body, "trim(\$text) === ''"), 'Der Fall wird nicht unterschieden');
assertTrue(
strpos($body, 'Anhang nicht gespeichert') < strpos($body, '$mgr->addComment'),
'Die Meldung käme erst nach dem Speichern'
);
});
test('Ein eingereichter Antrag meldet nicht übernommene Dateien', function () use ($rumpf, $controller) {
assertTrue(str_contains($controller(), 'private array $uploadFehler = []'), 'Kein Sammelpunkt');
foreach (['store', 'submitDraft'] as $weg) {
$body = $rumpf($weg);
assertTrue(str_contains($body, 'Nicht übernommen'), 'Stummer Verlust in: ' . $weg);
assertTrue(str_contains($body, "'warning'"), 'Verlust gilt als Erfolg in: ' . $weg);
}
});
test('Der Server-seitige Grund landet im Protokoll', function () use ($rumpf) {
// „nicht beschreibbar" ist für die Bedienung eine Meldung, für den
// Betrieb ein Hinweis auf die Ursache.
$body = $rumpf('storeUpload');
assertTrue(substr_count($body, 'error_log(') >= 2, 'Der Betrieb erfährt die Ursache nicht');
});
// ----------------------------------------------------------------
// Speicherort unabhängig vom Alter der Konfiguration
// ----------------------------------------------------------------
test('Der Speicherort steht auch ohne Eintrag in der Konfiguration fest', function () {
// `config/` wird beim Update nicht ersetzt: Auf Instanzen, die vor der
// Einführung des Eintrags eingerichtet wurden, fehlt er dauerhaft.
$ohne = \OpenNIT\Core\Paths::uploads(null);
assertTrue($ohne !== '', 'Ohne Konfiguration kein Pfad');
assertTrue(str_ends_with($ohne, '/storage/uploads'), 'Der Standard weicht von config/app.php ab: ' . $ohne);
assertFalse(str_ends_with($ohne, '/'), 'Der Pfad endet auf einem Schrägstrich');
});
test('Ein Eintrag in der Konfiguration hat Vorrang', function () {
assertSame(
'/var/daten/uploads',
\OpenNIT\Core\Paths::uploads(['app' => ['paths' => ['uploads' => '/var/daten/uploads/']]])
);
// Ein leerer Eintrag zählt wie keiner.
assertTrue(str_ends_with(\OpenNIT\Core\Paths::uploads(['app' => ['paths' => ['uploads' => '']]]), '/storage/uploads'));
});
test('Ablage und Auslieferung landen am selben Ort', function () use ($wurzel) {
// Wer die Datei schreibt und wer sie ausliefert, müssen sich einig sein –
// sonst entstehen Dateien, die niemand mehr abrufen kann.
$app = (string)file_get_contents($wurzel . '/src/Core/Application.php');
preg_match("/\\\$uploadsPath\s*=\s*(.+?);/", $app, $t);
$auslieferung = $t[1] ?? '';
assertTrue($auslieferung !== '', 'Die Auslieferung wurde nicht gefunden');
assertTrue(
str_contains($auslieferung, "/storage/uploads") || str_contains($auslieferung, 'Paths::uploads'),
'Die Auslieferung weicht ab: ' . $auslieferung
);
// Ohne Konfiguration muss beides denselben Ort ergeben.
assertSame(
dirname($wurzel) . '/' . basename($wurzel) . '/storage/uploads',
\OpenNIT\Core\Paths::uploads(null),
'Ablage und Auslieferung driften ohne Konfiguration auseinander'
);
});
test('UPLOADS_PATH wird nicht hinter dem Rücken der Auslieferung gelesen', function () use ($wurzel) {
// Auf einer Instanz mit altem config/app.php liegen die bisherigen
// Dateien unter storage/uploads; ein abweichender Ort verlöre sie.
$quelle = (string)file_get_contents($wurzel . '/src/Core/Paths.php');
$rumpf = substr($quelle, (int)strpos($quelle, 'public static function uploads'));
assertFalse(str_contains($rumpf, 'UPLOADS_PATH'), 'Der Ausweichwert weicht von der Auslieferung ab');
});
test('Anhänge und ihre Löschung lesen denselben Speicherort', function () use ($wurzel, $rumpf) {
// Läge hier einmal der Standard und einmal nichts, würde die
// Anonymisierung die Dateien stehen lassen, die der Upload angelegt hat.
$mgr = (string)file_get_contents($wurzel . '/src/Workflows/WorkflowManager.php');
assertTrue(str_contains($rumpf('storeUpload'), 'Paths::uploads('), 'Der Upload löst den Pfad selbst auf');
assertTrue(str_contains($mgr, 'realpath(Paths::uploads('), 'Die Löschung löst den Pfad selbst auf');
});
test('Niemand liest den Speicherort ohne Ausweichwert', function () use ($wurzel) {
// Ein nackter Zugriff auf app.paths.uploads liefert auf einer alten
// Instanz null – und der Aufrufer legt die Datei dann nirgends ab.
$treffer = [];
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($wurzel . '/src'));
foreach ($it as $datei) {
if (!$datei->isFile() || $datei->getExtension() !== 'php') {
continue;
}
$inhalt = (string)file_get_contents($datei->getPathname());
foreach (explode("\n", $inhalt) as $nr => $zeile) {
if (!str_contains($zeile, "app.paths.uploads") && !str_contains($zeile, "['paths']['uploads']")) {
continue;
}
// Kommentare nennen den Pfad, lesen ihn aber nicht.
$kopf = ltrim($zeile);
if (str_starts_with($kopf, '//') || str_starts_with($kopf, '*') || str_starts_with($kopf, '/*')) {
continue;
}
// Zulässig: Ausweichwert per ?? oder Auflösung über Paths.
if (str_contains($zeile, '??') || str_contains($zeile, 'Paths::uploads')) {
continue;
}
$treffer[] = basename($datei->getPathname()) . ':' . ($nr + 1);
}
}
assertSame([], $treffer, 'Ohne Ausweichwert: ' . implode(', ', $treffer));
});
// ----------------------------------------------------------------
// PDF-Nachweis
// ----------------------------------------------------------------
/** Werte einer ENUM-Spalte aus dem Schema. */
$enum = static function (string $spalte, ?string $tabelle = null) use ($wurzel): array {
$sql = (string)file_get_contents($wurzel . '/database/migrations/001_initial_schema.sql');
if ($tabelle !== null) {
$von = strpos($sql, 'CREATE TABLE IF NOT EXISTS `' . $tabelle . '`');
$sql = $von === false ? '' : substr($sql, $von, 3000);
preg_match('/`' . $spalte . '`\s+ENUM\(([^)]+)\)/', $sql, $t);
} else {
// Die zuletzt gesetzte Definition gewinnt.
preg_match_all('/MODIFY COLUMN `' . $spalte . '` ENUM\(([^)]+)\)/', $sql, $alle);
$t = [1 => end($alle[1]) ?: ''];
}
preg_match_all("/'([a-z_]+)'/", $t[1] ?? '', $werte);
return $werte[1];
};
test('Jeder Status im Schema hat eine deutsche Bezeichnung', function () use ($enum) {
foreach ([
['status', 'workflow_requests', [WorkflowManager::class, 'statusLabel']],
['status', 'request_step_states', [WorkflowManager::class, 'stepStatusLabel']],
] as [$spalte, $tabelle, $beschriftung]) {
$werte = $enum($spalte, $tabelle);
assertTrue(count($werte) >= 5, 'Schema nicht gelesen: ' . $tabelle);
foreach ($werte as $wert) {
assertTrue($beschriftung($wert) !== $wert, 'Ohne Bezeichnung in ' . $tabelle . ': ' . $wert);
}
}
});
test('Jede Eintragsart im Verlauf hat eine deutsche Bezeichnung', function () use ($enum) {
$werte = $enum('decision');
assertTrue(in_array('comment', $werte, true), 'Schema nicht gelesen – comment fehlt');
assertTrue(in_array('forward', $werte, true), 'Schema nicht gelesen – forward fehlt');
foreach ($werte as $wert) {
assertTrue(WorkflowManager::decisionLabel($wert) !== $wert, 'Ohne Bezeichnung: ' . $wert);
}
});
test('Der PDF-Nachweis zeigt keine Rohwerte der Datenbank', function () use ($rumpf) {
// Der Nachweis geht an Prüfer; „approved" gehört dort nicht hin.
$body = $rumpf('buildPdfHtml');
foreach (['statusLabel', 'stepStatusLabel', 'decisionLabel'] as $beschriftung) {
assertTrue(str_contains($body, $beschriftung . '('), 'Rohwert im Nachweis: ' . $beschriftung);
}
assertFalse(str_contains($body, "\$e(\$req['status'])"), 'Antragsstatus unbeschriftet');
assertFalse(str_contains($body, "\$e(\$s['status'])"), 'Stationsstatus unbeschriftet');
assertFalse(str_contains($body, "\$e(\$d['decision'])"), 'Eintragsart unbeschriftet');
});
};