From 8a7939bee23775e863e67998aa989af53b1c8bef Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 3 Aug 2026 18:11:40 +0200 Subject: [PATCH] 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 --- tests/CheckTokenTest.php | 89 ++++++ tests/ColorTest.php | 48 ++++ tests/DomainCheckerTest.php | 125 +++++++++ tests/LogoStoreTest.php | 190 +++++++++++++ tests/MigrationUpgradeTest.php | 175 ++++++++++++ tests/PunycodeTest.php | 100 +++++++ tests/QrRendererTest.php | 164 +++++++++++ tests/RedirectTest.php | 407 ++++++++++++++++++++++++++++ tests/Security/ClientIpTest.php | 194 +++++++++++++ tests/Security/SvgSanitizerTest.php | 195 +++++++++++++ tests/ShortDomainTest.php | 123 +++++++++ tests/SlugTest.php | 143 ++++++++++ tests/UrlValidatorTest.php | 189 +++++++++++++ tests/UserAgentParserTest.php | 86 ++++++ 14 files changed, 2228 insertions(+) create mode 100644 tests/CheckTokenTest.php create mode 100644 tests/ColorTest.php create mode 100644 tests/DomainCheckerTest.php create mode 100644 tests/LogoStoreTest.php create mode 100644 tests/MigrationUpgradeTest.php create mode 100644 tests/PunycodeTest.php create mode 100644 tests/QrRendererTest.php create mode 100644 tests/RedirectTest.php create mode 100644 tests/Security/ClientIpTest.php create mode 100644 tests/Security/SvgSanitizerTest.php create mode 100644 tests/ShortDomainTest.php create mode 100644 tests/SlugTest.php create mode 100644 tests/UrlValidatorTest.php create mode 100644 tests/UserAgentParserTest.php diff --git a/tests/CheckTokenTest.php b/tests/CheckTokenTest.php new file mode 100644 index 0000000..357929c --- /dev/null +++ b/tests/CheckTokenTest.php @@ -0,0 +1,89 @@ + + */ + 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); + } +} diff --git a/tests/ColorTest.php b/tests/ColorTest.php new file mode 100644 index 0000000..39eb823 --- /dev/null +++ b/tests/ColorTest.php @@ -0,0 +1,48 @@ +migrate(); + + $this->settings = new SettingRepository($db); + $this->checker = new DomainChecker($this->settings); + } + + /** + * @param array $extra + * @return array + */ + 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); + } + } +} diff --git a/tests/LogoStoreTest.php b/tests/LogoStoreTest.php new file mode 100644 index 0000000..a7f139e --- /dev/null +++ b/tests/LogoStoreTest.php @@ -0,0 +1,190 @@ +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 + */ + 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', 'uploadDir, 100); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/hoechstens/'); + + $store->store($this->upload('gross.png', $this->validPng()), 1); + } + + public function testSvgIsStrippedOfScriptsAndHandlers(): void + { + $svg = '' + . '' + . '' + . 'x' + . ''; + + $filename = $this->store()->store($this->upload('logo.svg', $svg), 3); + $stored = (string) file_get_contents($this->uploadDir . '/' . $filename); + + self::assertStringEndsWith('.svg', $filename); + self::assertStringNotContainsString('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); + } +} diff --git a/tests/MigrationUpgradeTest.php b/tests/MigrationUpgradeTest.php new file mode 100644 index 0000000..f70c6cd --- /dev/null +++ b/tests/MigrationUpgradeTest.php @@ -0,0 +1,175 @@ +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); + } +} diff --git a/tests/PunycodeTest.php b/tests/PunycodeTest.php new file mode 100644 index 0000000..8faed05 --- /dev/null +++ b/tests/PunycodeTest.php @@ -0,0 +1,100 @@ + + */ + 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); + } +} diff --git a/tests/QrRendererTest.php b/tests/QrRendererTest.php new file mode 100644 index 0000000..972fc8f --- /dev/null +++ b/tests/QrRendererTest.php @@ -0,0 +1,164 @@ +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 + */ + 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('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', + '' + . '', + ); + + $svg = $this->renderer()->svg( + 'https://kurz.test/mitlogo', + (new QrOptions())->withLogoPath('logo.svg'), + ); + + self::assertStringContainsString('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); + } +} diff --git a/tests/RedirectTest.php b/tests/RedirectTest.php new file mode 100644 index 0000000..464daca --- /dev/null +++ b/tests/RedirectTest.php @@ -0,0 +1,407 @@ +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 $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: [], + ); + } +} diff --git a/tests/Security/ClientIpTest.php b/tests/Security/ClientIpTest.php new file mode 100644 index 0000000..9228f41 --- /dev/null +++ b/tests/Security/ClientIpTest.php @@ -0,0 +1,194 @@ + $server + * @param list $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 + */ + 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">' . $close, + [' [ + $open . '' . $close, + ['foreignObject', ' [ + $open . '' . $close, + [' [ + $open . '' . $close, + ['alert(1)'], + ], + ]; + } + + /** + * @param list $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 = '' + . '' + . '' + . '' + . '' + . '' + . ''; + + $stored = $this->store($svg); + + self::assertStringContainsString(''; + + $stored = $this->store($svg); + + self::assertStringContainsString('data:image/png;base64,', $stored); + } + + public function testEncodedJavascriptHrefIsRemoved(): void + { + // HTML-Entities werden vor der Pruefung aufgeloest. + $svg = '' + . 'x'; + + $stored = $this->store($svg); + + self::assertStringNotContainsStringIgnoringCase('alert(1)', $stored); + self::assertStringNotContainsStringIgnoringCase('avascript:', $stored); + } +} diff --git a/tests/ShortDomainTest.php b/tests/ShortDomainTest.php new file mode 100644 index 0000000..7f25f85 --- /dev/null +++ b/tests/ShortDomainTest.php @@ -0,0 +1,123 @@ + + */ + 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 + */ + 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 + */ + 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')); + } +} diff --git a/tests/SlugTest.php b/tests/SlugTest.php new file mode 100644 index 0000000..10b9883 --- /dev/null +++ b/tests/SlugTest.php @@ -0,0 +1,143 @@ +expectException(\InvalidArgumentException::class); + Slug::random(3); + } + + /** + * @return array + */ + 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); + } + } + } +} diff --git a/tests/UrlValidatorTest.php b/tests/UrlValidatorTest.php new file mode 100644 index 0000000..58659c0 --- /dev/null +++ b/tests/UrlValidatorTest.php @@ -0,0 +1,189 @@ + + */ + 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,', 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://')); + } +} diff --git a/tests/UserAgentParserTest.php b/tests/UserAgentParserTest.php new file mode 100644 index 0000000..6c890cf --- /dev/null +++ b/tests/UserAgentParserTest.php @@ -0,0 +1,86 @@ + + */ + 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')); + } +}