OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

TicketAttachmentTest.php

8 KB
<?php

declare(strict_types=1);

use OpenNIT\Core\Database;
use OpenNIT\Tickets\TicketManager;

/**
 * Deckt die Aufnahme von Anhängen aus eingehenden Nachrichten ab.
 *
 * Der Medientyp eines Anhangs bestimmt die Software des Absenders. Ohne
 * Angleichung entschiede damit das Mailprogramm des Absenders darüber, ob ein
 * Anhang im Ticket ankommt – und ein abgewiesener Anhang verschwände, ohne dass
 * jemand davon erfährt.
 */
return static function (): void {

    /** Wird der Typ nach der Angleichung übernommen? */
    $uebernommen = static function (string $mime, string $name): bool {
        return in_array(
            TicketManager::normalizeAttachmentMime($mime, $name),
            TicketManager::getAllowedAttachmentMimeTypes(),
            true
        );
    };

    // ----------------------------------------------------------------
    // Schreibweisen einzelner Mailprogramme
    // ----------------------------------------------------------------

    test('Die Schreibweise von Outlook für ZIP wird angeglichen', function () {
        // Outlook meldet ZIP-Dateien als application/x-zip-compressed.
        assertSame(
            'application/zip',
            TicketManager::normalizeAttachmentMime('application/x-zip-compressed', 'bericht.zip')
        );
    });

    test('Verbreitete abweichende Schreibweisen werden zusammengeführt', function () {
        foreach ([
            ['image/jpg', 'image/jpeg'],
            ['image/pjpeg', 'image/jpeg'],
            ['image/x-png', 'image/png'],
            ['application/x-pdf', 'application/pdf'],
            ['application/x-gzip', 'application/gzip'],
            ['application/x-rar', 'application/vnd.rar'],
        ] as [$gemeldet, $erwartet]) {
            assertSame($erwartet, TicketManager::normalizeAttachmentMime($gemeldet, 'datei'), $gemeldet);
        }
    });

    test('Parameter und Großschreibung fallen weg', function () {
        // Der Kopfzeilen-Wert trägt oft den Dateinamen als Parameter mit.
        assertSame(
            'application/pdf',
            TicketManager::normalizeAttachmentMime('APPLICATION/PDF; name="Angebot.pdf"', 'Angebot.pdf')
        );
    });

    // ----------------------------------------------------------------
    // Unbestimmter Typ
    // ----------------------------------------------------------------

    test('Bei unbestimmtem Typ entscheidet die Dateiendung', function () use ($uebernommen) {
        // Viele Mailprogramme setzen für jeden Anhang application/octet-stream.
        foreach ([
            ['Angebot.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
            ['foto.JPG',     'image/jpeg'],
            ['tabelle.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
            ['notiz.txt',    'text/plain'],
        ] as [$name, $erwartet]) {
            assertSame($erwartet, TicketManager::normalizeAttachmentMime('application/octet-stream', $name), $name);
            assertTrue($uebernommen('application/octet-stream', $name), 'Verworfen: ' . $name);
        }
    });

    test('Auch ein leerer Typ wird über die Endung bestimmt', function () use ($uebernommen) {
        assertTrue($uebernommen('', 'tabelle.xlsx'));
    });

    test('Ohne verwertbare Endung bleibt der Typ unbestimmt', function () use ($uebernommen) {
        // Die Angleichung soll die Prüfung nicht aushebeln: Was sich weder aus
        // Typ noch Endung bestimmen lässt, wird weiterhin abgewiesen.
        assertSame(
            'application/octet-stream',
            TicketManager::normalizeAttachmentMime('application/octet-stream', 'unbekannt.xyz')
        );
        assertFalse($uebernommen('application/octet-stream', 'unbekannt.xyz'));
    });

    test('Ausführbare Inhalte werden nicht über die Endung hereingeholt', function () use ($uebernommen) {
        foreach (['schaden.exe', 'skript.php', 'start.bat', 'lauf.sh'] as $name) {
            assertFalse($uebernommen('application/octet-stream', $name), 'Angenommen: ' . $name);
        }
    });

    test('Wo der Typ aus dem Inhalt stammt, überstimmt die Endung ihn nicht', function () {
        // Bei Uploads über die Oberfläche ermittelt die Anwendung den Typ aus
        // dem Dateiinhalt. Würde die Endung ihn ersetzen, genügte ein passend
        // benannter Dateiname, um an der Prüfung vorbeizukommen.
        assertSame(
            'application/octet-stream',
            TicketManager::normalizeAttachmentMime('application/octet-stream', 'harmlos.docx', false)
        );

        // Die reine Angleichung der Schreibweise bleibt auch dort erhalten.
        assertSame(
            'application/zip',
            TicketManager::normalizeAttachmentMime('application/x-zip-compressed', 'x.zip', false)
        );
    });

    test('Weitergeleitete Nachrichten sind zugelassen', function () use ($uebernommen) {
        assertTrue($uebernommen('message/rfc822', 'weitergeleitet.eml'));
        assertTrue($uebernommen('application/octet-stream', 'weitergeleitet.eml'));
    });

    // ----------------------------------------------------------------
    // Vermerk über nicht übernommene Anhänge
    // ----------------------------------------------------------------

    /** Datenbank-Attrappe, die Einfügungen mitschreibt. */
    $db = static function (): Database {
        return new class extends Database {
            /** @var array<int,array{table:string,data:array}> */
            public array $inserts = [];

            public function __construct()
            {
                parent::__construct([]);
            }

            public function insert(string $table, array $data): int
            {
                $this->inserts[] = ['table' => $table, 'data' => $data];
                return 1;
            }
        };
    };

    /** Erzeugt den Manager ohne Konstruktor. */
    $manager = static function (Database $datenbank): TicketManager {
        $klasse = new ReflectionClass(TicketManager::class);
        $mgr    = $klasse->newInstanceWithoutConstructor();
        $feld   = $klasse->getProperty('db');
        $feld->setAccessible(true);
        $feld->setValue($mgr, $datenbank);
        return $mgr;
    };

    test('Ein nicht übernommener Anhang wird im Ticket vermerkt', function () use ($db, $manager) {
        $datenbank = $db();
        $manager($datenbank)->addDroppedAttachmentNote(7, [
            'Angebot.exe' => 'Dateityp nicht erlaubt (application/octet-stream)',
        ]);

        assertSame(1, count($datenbank->inserts), 'Kein Vermerk angelegt');
        $eintrag = $datenbank->inserts[0];
        assertSame('ticket_replies', $eintrag['table']);
        assertSame(7, $eintrag['data']['ticket_id']);

        // Intern, damit der Vermerk nicht an den Melder hinausgeht.
        assertSame(1, $eintrag['data']['is_internal']);

        // Dateiname und Grund müssen darin stehen – sonst hilft der Vermerk nicht.
        assertTrue(str_contains($eintrag['data']['body'], 'Angebot.exe'), 'Dateiname fehlt');
        assertTrue(str_contains($eintrag['data']['body'], 'Dateityp nicht erlaubt'), 'Grund fehlt');
    });

    test('Der Dateiname im Vermerk wird escaped', function () use ($db, $manager) {
        // Der Name stammt aus der eingegangenen Nachricht.
        $datenbank = $db();
        $manager($datenbank)->addDroppedAttachmentNote(7, [
            '<img src=x onerror=alert(1)>.exe' => 'Dateityp nicht erlaubt',
        ]);

        $body = $datenbank->inserts[0]['data']['body'];
        assertFalse(str_contains($body, '<img src=x'), 'Der Dateiname steht ungeschützt im Vermerk');
        assertTrue(str_contains($body, '&lt;img'), 'Der Dateiname fehlt ganz');
    });

    test('Ohne verworfene Anhänge entsteht kein Vermerk', function () use ($db, $manager) {
        $datenbank = $db();
        $manager($datenbank)->addDroppedAttachmentNote(7, []);
        assertSame(0, count($datenbank->inserts));
    });

    test('Mehrere verworfene Anhänge stehen in einem Vermerk', function () use ($db, $manager) {
        $datenbank = $db();
        $manager($datenbank)->addDroppedAttachmentNote(7, [
            'a.exe' => 'Dateityp nicht erlaubt',
            'b.iso' => 'Größe überschritten (300 MB, erlaubt sind 25 MB)',
        ]);

        assertSame(1, count($datenbank->inserts), 'Je Anhang ein eigener Vermerk');
        $body = $datenbank->inserts[0]['data']['body'];
        assertTrue(str_contains($body, 'a.exe') && str_contains($body, 'b.iso'));
        assertTrue(str_contains($body, '2 Anhänge'), 'Die Anzahl wird nicht genannt');
    });
};