Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Audit\AuditLogger;
use OpenNIT\Content\PageLinkManager;
use OpenNIT\Core\Database;
/**
* Deckt die Verknüpfung von Dokuseiten mit Geräten, Verträgen und Diensten ab.
*
* Das Ziel liegt je nach Art in einer anderen Tabelle, deshalb trägt die
* Zuordnung keinen Fremdschlüssel darauf. Daraus folgen die beiden Zusagen,
* die hier festgehalten werden: Verschwindet ein Ziel, verschwindet auch die
* Verknüpfung – und solange sie doch einmal zurückbleibt, taucht sie nirgends
* auf, weil jede Abfrage per INNER JOIN mit der Zieltabelle verbindet.
*
* Der dritte Punkt ist der Zugang: Eine Art ist nur verknüpfbar, wenn ihr
* Modul läuft **und** die Person es lesen darf. Sonst führte eine Verknüpfung
* auf eine Seite ohne Zugang – und die Trefferliste nennte Namen daraus.
*/
return static function (): void {
$wurzel = dirname(__DIR__);
$quelle = static fn (string $pfad): string => (string)file_get_contents($wurzel . '/' . $pfad);
/**
* PageLinkManager mit einer Datenbank-Attrappe, die Schreibvorgänge
* sammelt und beliebige Lesevorgänge leer beantwortet.
*/
$manager = static function (int $vorhandene = 0): array {
$db = new class($vorhandene) extends Database {
/** @var list<array<string,mixed>> */
public array $writes = [];
public function __construct(private int $vorhandene)
{
parent::__construct([]);
}
public function fetchColumn(string $sql, array $params = [], int $column = 0): mixed
{
return $this->vorhandene;
}
public function fetchAll(string $sql, array $params = []): array
{
return [];
}
public function fetchOne(string $sql, array $params = []): ?array
{
return null;
}
public function insert(string $table, array $data): int
{
$this->writes[] = ['op' => 'insert', 'table' => $table] + $data;
return 1;
}
public function delete(string $table, string $where, array $params = []): int
{
$this->writes[] = ['op' => 'delete', 'table' => $table, 'params' => $params];
return 1;
}
};
return [new PageLinkManager($db, new AuditLogger($db)), $db];
};
// ----------------------------------------------------------------
// Nur bekannte Objektarten
// ----------------------------------------------------------------
test('Eine unbekannte Objektart wird nirgends angefasst', function () use ($manager) {
// Die Art kommt aus der Adresse (/pages/1/object-links/{type}/{id}) und
// ist damit frei wählbar; sie darf nirgends in eine Abfrage geraten.
[$m, $db] = $manager();
assertFalse($m->linkObject(1, 'user', 5, 7), 'Unbekannte Art wurde verknüpft');
assertFalse($m->unlinkObject(1, 'user', 5, 7), 'Unbekannte Art wurde entfernt');
assertSame(0, $m->purgeObject('user', 5));
assertSame(null, $m->findObject('user', 5));
assertSame([], $m->getPagesForObject('user', 5));
assertSame([], $m->getPagesForObjects('user', [5]));
assertSame([], $m->getLinkedObjects(1, ['user']));
assertSame(0, count($db->writes), 'Es wurde trotzdem geschrieben');
});
test('Die drei erlaubten Arten stehen an einer Stelle', function () {
assertSame(['asset', 'contract', 'service'], array_keys(PageLinkManager::OBJECT_TYPES));
foreach (PageLinkManager::OBJECT_TYPES as $typ => $art) {
foreach (['label', 'plural', 'module', 'capability', 'icon'] as $feld) {
assertTrue(!empty($art[$feld]), "{$typ}: {$feld} fehlt");
}
assertTrue(array_key_exists('url', $art), "{$typ}: url fehlt");
}
});
// ----------------------------------------------------------------
// Verknüpfen und lösen
// ----------------------------------------------------------------
test('Dieselbe Verknüpfung entsteht kein zweites Mal', function () use ($manager) {
[$m, $db] = $manager(1); // die Zählung meldet einen vorhandenen Eintrag
assertFalse($m->linkObject(1, 'asset', 5, 7), 'Die Verknüpfung wurde doppelt angelegt');
assertSame(0, count($db->writes));
});
test('Eine neue Verknüpfung landet mit Art und Ziel in der Tabelle', function () use ($manager) {
[$m, $db] = $manager(0);
assertTrue($m->linkObject(4, 'contract', 9, 7));
$eintrag = $db->writes[0];
assertSame('insert', $eintrag['op']);
assertSame('page_object_links', $eintrag['table']);
assertSame(4, $eintrag['page_id']);
assertSame('contract', $eintrag['object_type']);
assertSame(9, $eintrag['object_id']);
assertSame(7, $eintrag['created_by']);
});
test('Ein Ziel ohne ID wird nicht verknüpft', function () use ($manager) {
[$m, $db] = $manager(0);
assertFalse($m->linkObject(4, 'asset', 0, 7));
assertSame(0, count($db->writes));
});
// ----------------------------------------------------------------
// Zurückgebliebene Zeilen
// ----------------------------------------------------------------
test('Jede Leseabfrage verbindet fest mit ihrer Zieltabelle', function () use ($quelle) {
// Ohne INNER JOIN erschiene eine Zeile, deren Ziel längst gelöscht ist,
// als Verknüpfung ohne Inhalt.
$src = $quelle('src/Content/PageLinkManager.php');
foreach (['getLinkedObjects', 'getPagesForObject', 'getPagesForObjects'] as $methode) {
$start = strpos($src, 'public function ' . $methode . '(');
assertTrue($start !== false, "{$methode}() fehlt");
$ende = strpos($src, "\n /**", $start);
$body = substr($src, $start, ($ende ?: strlen($src)) - $start);
assertTrue(str_contains($body, 'INNER JOIN'), "{$methode}() verbindet nicht fest");
assertFalse(str_contains($body, 'LEFT JOIN pages'), "{$methode}() lässt Seiten ohne Gegenstück zu");
}
});
test('Das Löschen eines Ziels räumt seine Verknüpfungen ab', function () use ($quelle) {
// Die Zuordnung trägt keinen Fremdschlüssel auf das Ziel; ohne diesen
// Schritt zeigte eine Zeile auf eine ID, die neu vergeben werden kann.
$faelle = [
['src/Controllers/AssetController.php', "purgeObject('asset'", 'DELETE FROM assets WHERE id = ?'],
['src/Content/ContractManager.php', "purgeObject('contract'", "delete('contracts', 'id = ?'"],
['src/Content/ServiceManager.php', "purgeObject('service'", "delete('services', 'id = ?'"],
];
foreach ($faelle as [$datei, $aufraeumen, $loeschen]) {
$src = $quelle($datei);
assertTrue(str_contains($src, $aufraeumen), "{$datei}: räumt nicht auf");
// Und zwar vor dem Löschen – danach ist die ID wieder frei.
$a = strpos($src, $aufraeumen);
$l = strpos($src, $loeschen);
assertTrue($l !== false, "{$datei}: Löschvorgang nicht gefunden");
assertTrue($a < $l, "{$datei}: wird erst nach dem Löschen aufgeräumt");
}
});
// ----------------------------------------------------------------
// Zugang
// ----------------------------------------------------------------
test('Eine Art braucht aktives Modul und Leserecht', function () use ($quelle) {
$src = $quelle('src/Controllers/PageController.php');
$start = strpos($src, 'private function allowedObjectTypes(');
assertTrue($start !== false, 'allowedObjectTypes() fehlt');
$body = substr($src, $start, 700);
assertTrue(str_contains($body, "modules.' . \$art['module']"), 'Das Modul wird nicht geprüft');
assertTrue(str_contains($body, "\$this->can(\$art['capability'])"), 'Das Leserecht wird nicht geprüft');
assertTrue(str_contains($body, '&&'), 'Beides muss zutreffen, nicht eines von beiden');
});
test('Nur zulässige Arten und vorhandene Ziele werden verknüpft', function () use ($quelle) {
$src = $quelle('src/Controllers/PageController.php');
$start = strpos($src, 'public function addObjectLink(');
assertTrue($start !== false, 'addObjectLink() fehlt');
$body = substr($src, $start, 1600);
assertTrue(
str_contains($body, "in_array(\$type, \$this->allowedObjectTypes(), true)"),
'Die Art wird nicht gegen die erlaubten geprüft'
);
assertTrue(str_contains($body, 'findObject('), 'Das Ziel wird nicht auf Existenz geprüft');
// Beides muss vor dem Schreiben stehen.
$pruefung = strpos($body, 'allowedObjectTypes()');
$schreiben = strpos($body, 'linkObject(');
assertTrue($pruefung !== false && $schreiben !== false && $pruefung < $schreiben, 'Es wird vor der Prüfung geschrieben');
});
test('Der Weg zurück zeigt nur lesbare Themengebiete', function () use ($quelle) {
$src = $quelle('src/Controllers/BaseController.php');
$start = strpos($src, 'protected function verknuepfteDokuseiten(');
assertTrue($start !== false, 'verknuepfteDokuseiten() fehlt');
$body = substr($src, $start, 600);
assertTrue(str_contains($body, "getAccessibleTopics(\$this->currentUserId(), 'read_page')"), 'Die Themengebiete werden nicht eingegrenzt');
assertTrue(str_contains($body, 'getPagesForObject('), 'Es werden keine Seiten geholt');
});
// ----------------------------------------------------------------
// Oberfläche
// ----------------------------------------------------------------
test('Die Routen für Objekt-Verknüpfungen sind registriert', function () use ($quelle) {
$app = $quelle('src/Core/Application.php');
assertTrue(
str_contains($app, "post('/pages/{id}/object-links', 'PageController@addObjectLink'"),
'Die Route zum Verknüpfen fehlt'
);
assertTrue(
str_contains($app, "delete('/pages/{id}/object-links/{type}/{objectId}', 'PageController@removeObjectLink'"),
'Die Route zum Lösen fehlt'
);
});
test('Das Formular zum Lösen trägt den CSRF-Token', function () use ($quelle) {
$tpl = $quelle('templates/pages/show.php');
$start = strpos($tpl, '/object-links/');
assertTrue($start !== false, 'Die Objektliste fehlt auf der Seite');
$body = substr($tpl, $start, 600);
assertTrue(str_contains($body, 'name="_csrf"'), 'Ohne Token schlägt das Absenden fehl');
assertTrue(str_contains($body, 'name="_method" value="DELETE"'), 'Die Methode fehlt');
});
test('Der Weg zurück steht auf allen drei Zielseiten', function () use ($quelle) {
assertTrue(
str_contains($quelle('templates/assets/show.php'), '$linked_doc_pages'),
'Die Geräteseite zeigt keine Dokuseiten'
);
assertTrue(
str_contains($quelle('templates/contracts/show.php'), '$linked_doc_pages'),
'Die Vertragsseite zeigt keine Dokuseiten'
);
assertTrue(
str_contains($quelle('templates/services/index.php'), 'service-doc-chip'),
'Die Dienste-Kacheln zeigen keine Dokuseiten'
);
// Die Kacheln holen alle Seiten in einer Abfrage, nicht je Kachel eine.
$ctrl = $quelle('src/Controllers/ServiceController.php');
assertTrue(str_contains($ctrl, 'getPagesForObjects('), 'Die Dienste fragen je Kachel einzeln ab');
});
test('Jedes verwendete Symbol steht in der Emoji-Zuordnung', function () use ($quelle) {
// Ohne Eintrag bliebe es im Emoji-Modus als Font-Awesome-Symbol stehen.
$map = $quelle('src/Core/IconMap.php');
foreach (PageLinkManager::OBJECT_TYPES as $typ => $art) {
assertTrue(str_contains($map, "'" . $art['icon'] . "'"), "{$typ}: {$art['icon']} fehlt in der Zuordnung");
}
foreach (['fa-file-alt', 'fa-book-open'] as $icon) {
assertTrue(str_contains($map, "'" . $icon . "'"), "{$icon} fehlt in der Zuordnung");
}
});
// ----------------------------------------------------------------
// Ablage
// ----------------------------------------------------------------
test('Die Tabelle lässt kein Duplikat zu und hängt an der Seite', function () use ($quelle) {
$sql = $quelle('database/migrations/221_page_object_links.sql');
assertTrue(str_contains($sql, 'CREATE TABLE IF NOT EXISTS `page_object_links`'), 'Die Tabelle fehlt');
assertTrue(
str_contains($sql, 'UNIQUE KEY `uq_page_object` (`page_id`, `object_type`, `object_id`)'),
'Dieselbe Verknüpfung ließe sich zweimal anlegen'
);
assertTrue(
str_contains($sql, 'REFERENCES `pages`(`id`) ON DELETE CASCADE'),
'Nach dem Löschen einer Seite blieben ihre Verknüpfungen zurück'
);
// Für den Weg zurück wird nach Art und Ziel gesucht.
assertTrue(str_contains($sql, 'INDEX `idx_pol_object` (`object_type`, `object_id`)'), 'Der Index für den Weg zurück fehlt');
});
};