OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

HandbookExportTest.php

13 KB
<?php

declare(strict_types=1);

/**
 * Das Handbuch setzt mehrere Themengebiete zu einem PDF zusammen.
 *
 * Geprüft wird alles, was ohne Dompdf auskommt: das Auflösen der Auswahl,
 * der Aufbau des Dokuments und die Rechtegrenze im Controller. Das Rendern
 * selbst hängt an der Bibliothek und wird auf der laufenden Instanz geprüft.
 */

use OpenNIT\Core\Database;
use OpenNIT\Export\HandbookExporter;

return function (): void {

    $wurzel = dirname(__DIR__);

    /**
     * Datenbank-Doppel: liefert Themen und Seiten aus vorgegebenen Listen und
     * merkt sich, welche Abfragen mit welchen Werten liefen.
     */
    $db = static function (array $themen, array $seiten): Database {
        return new class($themen, $seiten) extends Database {
            /** @var list<array{sql:string, params:array}> */
            public array $abfragen = [];

            public function __construct(private array $themen, private array $seiten)
            {
                parent::__construct([]);
            }

            public function fetchAll(string $sql, array $params = []): array
            {
                $this->abfragen[] = ['sql' => $sql, 'params' => $params];

                if (str_contains($sql, 'FROM topics')) {
                    // Die Auswahl-Abfrage filtert nach IN (…), die Unterthemen-
                    // Abfrage nach LIKE. Beide liefern hier dieselbe Menge.
                    return $this->themen;
                }
                if (str_contains($sql, 'FROM pages')) {
                    return $this->seiten[(int)($params[0] ?? 0)] ?? [];
                }
                return [];
            }
        };
    };

    /** Prüf-Doppel für das Protokoll. */
    $audit = static function (): OpenNIT\Audit\AuditLogger {
        return new class extends OpenNIT\Audit\AuditLogger {
            public int $eintraege = 0;

            public function __construct()
            {
            }

            public function logWithUser(
                int $userId,
                string $actionCategory,
                string $actionType,
                ?string $objectType = null,
                ?int $objectId = null,
                ?string $objectName = null,
                ?array $details = null,
                string $result = 'success',
                string $riskLevel = 'low',
                ?array $oldValue = null,
                ?array $newValue = null
            ): int {
                $this->eintraege++;
                return 1;
            }
        };
    };

    /** Der Inhalts-Aufbereiter des Einzelexports, ohne Datenbankzugriff. */
    $pdf = static function (): OpenNIT\Export\PdfExporter {
        return new class extends OpenNIT\Export\PdfExporter {
            public function __construct()
            {
            }

            public function embedContentImages(string $content): string
            {
                return $content;
            }

            public function prepareContentTables(string $content): array
            {
                return ['html' => $content, 'orientation' => 'portrait'];
            }
        };
    };

    // ----------------------------------------------------------------
    // Aufbau des Dokuments
    // ----------------------------------------------------------------

    /** Baut das HTML, ohne zu rendern. */
    $html = static function (HandbookExporter $e, array $topicIds, string $titel, bool $unter = true): string {
        $sammeln = new ReflectionMethod($e, 'themenSammeln');
        $sammeln->setAccessible(true);
        $seitenOf = new ReflectionMethod($e, 'seitenDesThemas');
        $seitenOf->setAccessible(true);
        $bauen = new ReflectionMethod($e, 'html');
        $bauen->setAccessible(true);

        $inhalt = [];
        foreach ($sammeln->invoke($e, $topicIds, $unter) as $thema) {
            $inhalt[] = ['thema' => $thema, 'seiten' => $seitenOf->invoke($e, (int)$thema['id'])];
        }

        return (string)$bauen->invoke($e, $titel, $inhalt);
    };

    test('Deckblatt, Inhalt und Seiten stehen im Dokument', function () use ($db, $audit, $pdf, $html) {
        $themen = [
            ['id' => 1, 'name' => 'Netzwerk',  'description' => 'Alles zum Netz', 'path' => '/1/'],
            ['id' => 2, 'name' => 'Serverraum', 'description' => '',              'path' => '/1/2/'],
        ];
        $seiten = [
            1 => [['id' => 10, 'title' => 'VLANs', 'path' => '/10/', 'content_html' => '<p>Inhalt A</p>',
                   'version_number' => 3, 'version_date' => '2026-01-05 10:00:00', 'author_name' => 'A. Person']],
            2 => [['id' => 11, 'title' => 'Kühlung', 'path' => '/11/', 'content_html' => '<p>Inhalt B</p>',
                   'version_number' => 1, 'version_date' => '2026-02-02 08:00:00', 'author_name' => '']],
        ];

        $e = new HandbookExporter($db($themen, $seiten), $audit(), $pdf(), []);
        $doc = $html($e, [1], 'Betriebshandbuch');

        assertTrue(str_contains($doc, 'Betriebshandbuch'), 'Der Titel fehlt auf dem Deckblatt');
        assertTrue(str_contains($doc, '2 Themengebiete'), 'Die Zahl der Themen stimmt nicht');
        assertTrue(str_contains($doc, '2 Seiten'), 'Die Zahl der Seiten stimmt nicht');

        assertTrue(str_contains($doc, '<h2>Inhalt</h2>'), 'Das Inhaltsverzeichnis fehlt');
        assertTrue(str_contains($doc, 'hb-toc-thema'), 'Die Themen stehen nicht im Inhaltsverzeichnis');

        // Beide Seiten stehen mit Inhalt und Herkunft im Dokument.
        assertTrue(str_contains($doc, 'Inhalt A') && str_contains($doc, 'Inhalt B'), 'Ein Seiteninhalt fehlt');
        assertTrue(str_contains($doc, 'Version 3'), 'Die Version steht nicht an der Seite');
        assertTrue(str_contains($doc, 'A. Person'), 'Die Urheberin steht nicht an der Seite');

        // Jede Seite beginnt auf einem neuen Blatt: Deckblatt, Inhalt,
        // zwei Themenblätter und zwei Seitenblätter.
        assertSame(6, substr_count($doc, 'class="hb-blatt'), 'Die Zahl der Blätter stimmt nicht');
    });

    test('Ein Thema ohne Seiten wird benannt statt verschwiegen', function () use ($db, $audit, $pdf, $html) {
        $themen = [['id' => 7, 'name' => 'Leeres Thema', 'description' => '', 'path' => '/7/']];

        $e = new HandbookExporter($db($themen, []), $audit(), $pdf(), []);
        $doc = $html($e, [7], 'Handbuch');

        assertTrue(str_contains($doc, 'keine Seiten'), 'Das leere Thema wird im Inhalt nicht benannt');
        assertTrue(str_contains($doc, 'enthält keine Seiten'), 'Das leere Thema wird im Text nicht benannt');
    });

    test('Ohne Seiten in der gesamten Auswahl gibt es kein Dokument', function () use ($db, $audit, $pdf) {
        $themen = [['id' => 7, 'name' => 'Leer', 'description' => '', 'path' => '/7/']];
        $e = new HandbookExporter($db($themen, []), $audit(), $pdf(), []);

        $fehler = '';
        try {
            $e->export([7], 1, 'Handbuch');
        } catch (\InvalidArgumentException $ex) {
            $fehler = $ex->getMessage();
        }
        assertTrue(str_contains($fehler, 'keine Seiten'), 'Ein leeres Handbuch wird erzeugt statt abgelehnt');
    });

    test('Seiteninhalt wird als HTML übernommen, Titel dagegen maskiert', function () use ($db, $audit, $pdf, $html) {
        // Der Seiteninhalt ist bereits beim Speichern bereinigt und soll als
        // Auszeichnung wirken; Titel und Beschreibung sind reiner Text.
        $themen = [['id' => 1, 'name' => 'Thema <b>fett</b>', 'description' => '', 'path' => '/1/']];
        $seiten = [1 => [['id' => 9, 'title' => 'Titel & "Zitat"', 'path' => '/9/',
                          'content_html' => '<p><strong>echt fett</strong></p>',
                          'version_number' => 1, 'version_date' => '2026-01-01 00:00:00', 'author_name' => '']]];

        $e = new HandbookExporter($db($themen, $seiten), $audit(), $pdf(), []);
        $doc = $html($e, [1], 'Handbuch');

        assertTrue(str_contains($doc, '<strong>echt fett</strong>'), 'Der Seiteninhalt verliert seine Auszeichnung');
        assertTrue(str_contains($doc, 'Thema &lt;b&gt;fett&lt;/b&gt;'), 'Der Themenname wird nicht maskiert');
        assertTrue(str_contains($doc, 'Titel &amp; &quot;Zitat&quot;'), 'Der Seitentitel wird nicht maskiert');
    });

    // ----------------------------------------------------------------
    // Auswahl auflösen
    // ----------------------------------------------------------------

    test('Unterthemen kommen nur auf Wunsch mit', function () use ($db, $audit, $pdf) {
        $themen = [['id' => 1, 'name' => 'A', 'description' => '', 'path' => '/1/']];
        $doppel = $db($themen, []);
        $e = new HandbookExporter($doppel, $audit(), $pdf(), []);

        $sammeln = new ReflectionMethod($e, 'themenSammeln');
        $sammeln->setAccessible(true);

        $sammeln->invoke($e, [1], false);
        $ohne = array_filter($doppel->abfragen, static fn (array $a): bool => str_contains($a['sql'], 'LIKE'));
        assertSame(0, count($ohne), 'Ohne Wunsch werden Unterthemen nachgeladen');

        $doppel->abfragen = [];
        $sammeln->invoke($e, [1], true);
        $mit = array_filter($doppel->abfragen, static fn (array $a): bool => str_contains($a['sql'], 'LIKE'));
        assertTrue(count($mit) === 1, 'Unterthemen werden nicht über den Pfad nachgezogen');
    });

    test('Unbrauchbare IDs fallen weg, Dubletten ebenso', function () use ($db, $audit, $pdf) {
        $doppel = $db([['id' => 1, 'name' => 'A', 'description' => '', 'path' => '/1/']], []);
        $e = new HandbookExporter($doppel, $audit(), $pdf(), []);

        $sammeln = new ReflectionMethod($e, 'themenSammeln');
        $sammeln->setAccessible(true);
        $sammeln->invoke($e, [3, 3, 0, -1, 3], false);

        $auswahl = $doppel->abfragen[0];
        assertSame([3], $auswahl['params'], 'Die Auswahl wird nicht bereinigt');
        assertTrue(str_contains($auswahl['sql'], 'is_archived = FALSE'), 'Abgelegte Themen kommen mit');
    });

    test('Eine leere Auswahl fragt die Datenbank gar nicht', function () use ($db, $audit, $pdf) {
        $doppel = $db([], []);
        $e = new HandbookExporter($doppel, $audit(), $pdf(), []);

        $sammeln = new ReflectionMethod($e, 'themenSammeln');
        $sammeln->setAccessible(true);
        assertSame([], $sammeln->invoke($e, [0, -5], true), 'Aus einer leeren Auswahl entsteht etwas');
        assertSame(0, count($doppel->abfragen), 'Es wird trotz leerer Auswahl abgefragt');
    });

    // ----------------------------------------------------------------
    // Rechtegrenze
    // ----------------------------------------------------------------

    test('Ausgegeben wird nur, was die Person ohnehin ausgeben darf', function () use ($wurzel) {
        $steuerung = (string)file_get_contents($wurzel . '/src/Controllers/ExportController.php');

        // Die Auswahl aus dem Formular wird gegen die erlaubten Themen
        // geschnitten, statt ihr zu folgen.
        assertTrue(
            (bool)preg_match('/array_intersect\(.*\$gewaehlt\)/s', $steuerung),
            'Die Auswahl wird nicht gegen die Rechte geschnitten'
        );
        assertTrue(
            str_contains($steuerung, "getAccessibleTopics(\$this->currentUserId(), 'export_pdf')"),
            'Die erlaubten Themen kommen nicht aus der Rechteprüfung'
        );

        $routen = (string)file_get_contents($wurzel . '/src/Core/Application.php');
        foreach (["get('/export/handbook'", "post('/export/handbook'"] as $route) {
            $stelle = strpos($routen, $route);
            assertTrue($stelle !== false, 'Die Route ' . $route . ' fehlt');
            assertTrue(
                str_contains(substr($routen, $stelle, 200), "'capability' => 'export_pdf'"),
                'Die Route ' . $route . ' ist nicht an eine Berechtigung gebunden'
            );
        }
    });

    test('Die Seite ist aufgebaut wie die übrigen Modulseiten', function () use ($wurzel) {
        // Vorbild sind /budget und /ssl: volle Breite, Kopfzeile auf dem
        // Seitenhintergrund, erst darunter der Inhaltsbereich.
        $seite = (string)file_get_contents($wurzel . '/templates/export/handbook.php');

        assertTrue(str_contains($seite, "'full_width'    => true"), 'Die Seite nutzt nicht die volle Breite');

        $kopf   = strpos($seite, 'class="content-header"');
        $koerper = strpos($seite, 'class="page-body"');
        assertTrue($kopf !== false && $koerper !== false, 'Kopfzeile oder Inhaltsbereich fehlt');
        assertTrue($kopf < $koerper, 'Die Kopfzeile steckt im Inhaltsbereich statt darüber');
    });

    test('Das Formular trägt den CSRF-Token', function () use ($wurzel) {
        $seite = (string)file_get_contents($wurzel . '/templates/export/handbook.php');
        assertTrue(str_contains($seite, 'name="_csrf"'), 'Der Token fehlt');
        assertFalse(str_contains($seite, 'name="csrf_token"'), 'Der Token trägt den falschen Feldnamen');
    });
};