Quelltext des aktuellen Standes
<?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 <b>fett</b>'), 'Der Themenname wird nicht maskiert');
assertTrue(str_contains($doc, 'Titel & "Zitat"'), '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');
});
};