OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

PageObjectLinkTest.php

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