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.
190 lines
6.1 KiB
PHP
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);
|
|
}
|
|
}
|