Files
urlshort-qr-gen/tests/LogoStoreTest.php
T
florian.wilde 649f6ba247 fix: Lauffaehigkeit unter PHP 8.4 und 8.5 herstellen
Auf einem Hetzner-Webhosting mit PHP 8.5 brach die Anwendung beim ersten
Aufruf ab. Ausloeser war session.sid_length: seit PHP 8.4 als veraltet
gemarkt. Die Meldung wurde ausgegeben, damit waren Header raus - und
danach liess sich weder eine ini-Einstellung aendern noch eine Session
starten. Aus einer Randnotiz wurde so ein Totalausfall.

Behoben auf drei Ebenen:

1. Die veralteten Aufrufe sind weg. session.sid_length und
   session.sid_bits_per_character werden nicht mehr gesetzt; die
   Voreinstellungen liefern mit 128 Bit ohnehin genug Entropie.
   Ebenso entfallen curl_close(), imagedestroy() und finfo_close() -
   seit PHP 8.0 bzw. 8.5 wirkungslos und als veraltet gemeldet.

2. public/index.php puffert die Ausgabe von der ersten Zeile an. Viele
   Hoster erzwingen display_errors und lassen sich per ini_set() nicht
   davon abbringen. Gepuffert laesst sich Fremdausgabe vor dem Senden
   verwerfen und ins Fehlerprotokoll umleiten, statt die Antwort zu
   zerstoeren. Das schuetzt auch Binaerdownloads: eine einzelne
   Meldung haette bisher jedes PNG beschaedigt. Session::start()
   meldet ausserdem klar, wenn doch schon Ausgaben raus sind, statt
   eine Kaskade aus Warnungen zu erzeugen.

3. endroid/qr-code von 5.1 auf 6.0 angehoben. Version 5 markiert
   Parameter implizit als nullable, was PHP 8.4 bemaengelt - bei jedem
   erzeugten QR-Code. Version 6.1 waere ebenfalls verfuegbar, verlangt
   aber PHP 8.4+ und wuerde Hoster mit 8.2 oder 8.3 ausschliessen;
   6.0 laeuft ab 8.1.

Getestet wird jetzt gegen PHP 8.3, 8.4 und 8.5, jeweils mit
display_errors=On und error_reporting=E_ALL, damit Deprecations
sichtbar werden statt still im Log zu landen. Alle 232 Tests laufen auf
allen drei Versionen ohne eine einzige Meldung durch. Zusaetzlich ist
das Release-Paket in einer PHP-8.5-Umgebung mit fremdem Webroot
geprueft: Ersteinrichtung, Kurzlinks, Weiterleitung, QR-Codes und
Statistik, ohne Streuausgabe in Seiten oder Downloads.
2026-08-12 18:01:30 +02:00

190 lines
6.1 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Qr\LogoStore;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class LogoStoreTest extends TestCase
{
private string $uploadDir;
private string $tmpDir;
protected function setUp(): void
{
$suffix = bin2hex(random_bytes(4));
$this->uploadDir = sys_get_temp_dir() . '/urlshort-uploads-' . $suffix;
$this->tmpDir = sys_get_temp_dir() . '/urlshort-tmp-' . $suffix;
mkdir($this->uploadDir, 0775, true);
mkdir($this->tmpDir, 0775, true);
}
protected function tearDown(): void
{
foreach ([$this->uploadDir, $this->tmpDir] as $dir) {
foreach (glob($dir . '/*') ?: [] as $file) {
unlink($file);
}
if (is_dir($dir)) {
rmdir($dir);
}
}
}
private function store(): LogoStore
{
return new LogoStore($this->uploadDir, 1024 * 1024);
}
/**
* @return array<string,mixed>
*/
private function upload(string $name, string $contents): array
{
$path = $this->tmpDir . '/' . bin2hex(random_bytes(4));
file_put_contents($path, $contents);
return [
'name' => $name,
'tmp_name' => $path,
'size' => strlen($contents),
'error' => UPLOAD_ERR_OK,
];
}
private function validPng(int $width = 64, int $height = 64): string
{
$image = imagecreatetruecolor($width, $height);
self::assertNotFalse($image);
imagefilledrectangle($image, 0, 0, $width - 1, $height - 1, (int) imagecolorallocate($image, 11, 79, 158));
ob_start();
imagepng($image);
$data = (string) ob_get_clean();
return $data;
}
public function testValidPngIsStoredWithGeneratedName(): void
{
$filename = $this->store()->store($this->upload('mein logo.png', $this->validPng()), 7);
self::assertMatchesRegularExpression('/^logo-7-[a-f0-9]{12}\.png$/', $filename);
self::assertFileExists($this->uploadDir . '/' . $filename);
}
public function testCorruptPngIsRejected(): void
{
// Gueltiger PNG-Header, aber unbrauchbare Bilddaten: getimagesize()
// akzeptiert das, GD kann es nicht dekodieren.
$corrupt = base64_decode(
'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAHUlEQVQoz2NkYPjPQApgYhhVMKpgVMGoglEFpCsAAFXsAQEt4A0oAAAAAElFTkSuQmCC',
true,
);
self::assertIsString($corrupt);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/beschaedigt|gelesen/');
$this->store()->store($this->upload('kaputt.png', $corrupt), 1);
}
public function testOversizedImageIsRejected(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/zu gross/');
$this->store()->store($this->upload('riesig.png', $this->validPng(4200, 10)), 1);
}
public function testNonImageIsRejected(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/Nur PNG, JPG und SVG/');
$this->store()->store($this->upload('schad.php', '<?php echo 1;'), 1);
}
public function testTooLargeFileIsRejected(): void
{
$store = new LogoStore($this->uploadDir, 100);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/hoechstens/');
$store->store($this->upload('gross.png', $this->validPng()), 1);
}
public function testSvgIsStrippedOfScriptsAndHandlers(): void
{
$svg = '<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10" onload="alert(1)">'
. '<script>alert(2)</script>'
. '<circle cx="5" cy="5" r="5" fill="#ff0000" onclick="alert(3)"/>'
. '<a href="javascript:alert(4)">x</a>'
. '</svg>';
$filename = $this->store()->store($this->upload('logo.svg', $svg), 3);
$stored = (string) file_get_contents($this->uploadDir . '/' . $filename);
self::assertStringEndsWith('.svg', $filename);
self::assertStringNotContainsString('<script', $stored);
self::assertStringNotContainsString('onload', $stored);
self::assertStringNotContainsString('onclick', $stored);
self::assertStringNotContainsString('javascript:', $stored);
// Der eigentliche Bildinhalt bleibt erhalten.
self::assertStringContainsString('<circle', $stored);
self::assertStringContainsString('#ff0000', $stored);
}
public function testSvgWithoutSvgElementIsRejected(): void
{
$this->expectException(RuntimeException::class);
$this->store()->store($this->upload('fake.svg', 'nur text, kein svg'), 1);
}
public function testPathForIgnoresDirectoryTraversal(): void
{
$store = $this->store();
$store->store($this->upload('logo.png', $this->validPng()), 5);
self::assertNull($store->pathFor('../../../etc/passwd'));
self::assertNull($store->pathFor('..\\..\\windows\\win.ini'));
self::assertNull($store->pathFor(null));
self::assertNull($store->pathFor(''));
}
public function testDeleteRemovesTheFile(): void
{
$store = $this->store();
$filename = $store->store($this->upload('logo.png', $this->validPng()), 9);
self::assertFileExists($this->uploadDir . '/' . $filename);
$store->delete($filename);
self::assertFileDoesNotExist($this->uploadDir . '/' . $filename);
}
public function testIsSvgDetection(): void
{
$store = $this->store();
self::assertTrue($store->isSvg('logo-1-abc.svg'));
self::assertTrue($store->isSvg('LOGO.SVG'));
self::assertFalse($store->isSvg('logo-1-abc.png'));
self::assertFalse($store->isSvg(null));
}
public function testMissingUploadIsReported(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageMatches('/keine Datei/');
$this->store()->store(['error' => UPLOAD_ERR_NO_FILE], 1);
}
}