test: Testsuite fuer Kernlogik, QR-Codes, Domains und Sicherheit

232 Tests, unter anderem:
- Slug-Erzeugung, Kollisionsbehandlung und Validierung
- URL-Validierung inklusive IDN, Prozentkodierung und abgelehnter
  Protokolle; Punycode gegengeprueft gegen idn_to_ascii
- QR-Erzeugung in allen Groessen, mit Logo und Level-H-Automatik
- Weiterleitung gegen eine echte SQLite-Datenbank samt Domain-
  Aufloesung ueber den Host-Header
- Update einer bestehenden Installation auf mehrere Domains
- Angriffsseitig: gefaelschte Proxy-Header, Host-Header-Injektion,
  SVG mit Skripten, XXE und externen Referenzen
This commit is contained in:
2026-08-03 18:11:40 +02:00
parent 73a9bb09b1
commit 8a7939bee2
14 changed files with 2228 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
<?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();
imagedestroy($image);
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);
}
}