OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

WorkflowUploadFeedbackTest.php

14 KB
<?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');
    });
};