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

232 Tests, unter anderem:
- Slug-Erzeugung, Kollisionsbehandlung und Validierung
- URL-Validierung inklusive IDN, Prozentkodierung und abgelehnter
  Protokolle; Punycode gegengeprueft gegen idn_to_ascii
- QR-Erzeugung in allen Groessen, mit Logo und Level-H-Automatik
- Weiterleitung gegen eine echte SQLite-Datenbank samt Domain-
  Aufloesung ueber den Host-Header
- Update einer bestehenden Installation auf mehrere Domains
- Angriffsseitig: gefaelschte Proxy-Header, Host-Header-Injektion,
  SVG mit Skripten, XXE und externen Referenzen
This commit is contained in:
2026-08-03 18:11:40 +02:00
parent 73a9bb09b1
commit 8a7939bee2
14 changed files with 2228 additions and 0 deletions
+407
View File
@@ -0,0 +1,407 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Config;
use App\Controller\RedirectController;
use App\Database;
use App\Http\Request;
use App\Migrator;
use App\Repository\DomainRepository;
use App\Repository\LinkRepository;
use App\Repository\VisitRepository;
use App\View;
use PHPUnit\Framework\TestCase;
/**
* Integrationstest der Weiterleitung gegen eine echte SQLite-Datenbank
* im Arbeitsspeicher.
*/
final class RedirectTest extends TestCase
{
private const BASE_URL = 'https://short.example.de';
private Database $db;
private LinkRepository $links;
private VisitRepository $visits;
private DomainRepository $domains;
private string $rootDir;
private int $defaultDomainId;
protected function setUp(): void
{
$this->rootDir = dirname(__DIR__);
$this->db = Database::sqlite(':memory:');
(new Migrator($this->db, $this->rootDir . '/migrations'))->migrate();
$this->links = new LinkRepository($this->db);
$this->visits = new VisitRepository($this->db);
$this->domains = new DomainRepository($this->db);
$this->defaultDomainId = (int) $this->domains->defaultDomain(self::BASE_URL)['id'];
}
public function testMigrationsCreateAllTables(): void
{
$tables = array_column(
$this->db->fetchAll("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name"),
'name',
);
foreach (['links', 'visits', 'qr_designs', 'settings', 'login_attempts', 'domains', 'migrations'] as $table) {
self::assertContains($table, $tables);
}
}
public function testMigrationsAreIdempotent(): void
{
$applied = (new Migrator($this->db, $this->rootDir . '/migrations'))->migrate();
self::assertSame([], $applied, 'Ein zweiter Lauf darf nichts erneut anwenden.');
}
public function testDefaultDomainIsDerivedFromBaseUrl(): void
{
$domain = $this->domains->defaultDomain(self::BASE_URL);
self::assertSame('short.example.de', $domain['host']);
self::assertSame(self::BASE_URL, $domain['base_url']);
self::assertSame(1, (int) $domain['is_default']);
self::assertSame(1, $this->domains->count(), 'Die Standarddomain darf nur einmal angelegt werden.');
}
public function testExistingLinksAreAssignedToTheDefaultDomain(): void
{
// Zustand nach der Migration: Bestandslink ohne Domain.
$this->db->execute(
'INSERT INTO links (domain_id, slug, target_url, is_active, click_count, created_at, updated_at)
VALUES (0, ?, ?, 1, 0, ?, ?)',
['bestand', 'https://www.beispiel.de/alt', gmdate('Y-m-d H:i:s'), gmdate('Y-m-d H:i:s')],
);
// Frische Datenbank simulieren, in der noch keine Domain existiert.
$this->db->execute('DELETE FROM domains');
$domainId = (int) $this->domains->defaultDomain(self::BASE_URL)['id'];
$link = $this->db->fetchOne('SELECT * FROM links WHERE slug = ?', ['bestand']);
self::assertNotNull($link);
self::assertSame($domainId, (int) $link['domain_id']);
}
public function testKnownSlugRedirectsWithConfiguredStatus(): void
{
$this->links->create($this->defaultDomainId, 'messe2026', 'https://www.beispiel.de/messe');
$response = $this->controller(['REDIRECT_STATUS' => '302'])
->handle($this->request('/messe2026'), ['slug' => 'messe2026']);
self::assertSame(302, $response->status());
self::assertSame('https://www.beispiel.de/messe', $response->headers()['Location']);
self::assertStringContainsString('no-store', $response->headers()['Cache-Control']);
}
public function testRedirectStatusIsConfigurable(): void
{
$this->links->create($this->defaultDomainId, 'dauerhaft', 'https://www.beispiel.de/');
$response = $this->controller(['REDIRECT_STATUS' => '301'])
->handle($this->request('/dauerhaft'), ['slug' => 'dauerhaft']);
self::assertSame(301, $response->status());
}
public function testInvalidRedirectStatusFallsBackTo302(): void
{
$this->links->create($this->defaultDomainId, 'krumm', 'https://www.beispiel.de/');
$response = $this->controller(['REDIRECT_STATUS' => '418'])
->handle($this->request('/krumm'), ['slug' => 'krumm']);
self::assertSame(302, $response->status());
}
public function testUnknownSlugRendersFriendly404(): void
{
$response = $this->controller()->handle($this->request('/gibtesnicht'), ['slug' => 'gibtesnicht']);
self::assertSame(404, $response->status());
self::assertArrayNotHasKey('Location', $response->headers());
self::assertStringContainsString('404', $response->body());
self::assertStringContainsString('gibtesnicht', $response->body());
}
public function testInactiveLinkIsNotRedirected(): void
{
$id = $this->links->create($this->defaultDomainId, 'pausiert', 'https://www.beispiel.de/');
$this->links->setActive($id, false);
$response = $this->controller()->handle($this->request('/pausiert'), ['slug' => 'pausiert']);
self::assertSame(404, $response->status());
}
// --- Mehrere Domains ---------------------------------------------
public function testSameSlugOnTwoDomainsLeadsToDifferentTargets(): void
{
$qrId = $this->domains->create('https://qr.example.de', 'QR-Codes', false);
$this->links->create($this->defaultDomainId, 'messe', 'https://www.beispiel.de/kurzlink-ziel');
$this->links->create($qrId, 'messe', 'https://www.beispiel.de/qr-ziel');
$viaShort = $this->controller()->handle(
$this->request('/messe', host: 'short.example.de'),
['slug' => 'messe'],
);
$viaQr = $this->controller()->handle(
$this->request('/messe', host: 'qr.example.de'),
['slug' => 'messe'],
);
self::assertSame('https://www.beispiel.de/kurzlink-ziel', $viaShort->headers()['Location']);
self::assertSame('https://www.beispiel.de/qr-ziel', $viaQr->headers()['Location']);
}
public function testSlugOfAnotherDomainIsNotReachable(): void
{
$qrId = $this->domains->create('https://qr.example.de', null, false);
$this->links->create($qrId, 'nur-qr', 'https://www.beispiel.de/');
$response = $this->controller()->handle(
$this->request('/nur-qr', host: 'short.example.de'),
['slug' => 'nur-qr'],
);
self::assertSame(404, $response->status());
}
public function testSameSlugMayExistOncePerDomain(): void
{
$qrId = $this->domains->create('https://qr.example.de', null, false);
$this->links->create($this->defaultDomainId, 'doppelt', 'https://www.beispiel.de/a');
self::assertTrue($this->links->slugExists('doppelt', $this->defaultDomainId));
self::assertFalse($this->links->slugExists('doppelt', $qrId));
$this->links->create($qrId, 'doppelt', 'https://www.beispiel.de/b');
self::assertTrue($this->links->slugExists('doppelt', $qrId));
}
public function testDuplicateSlugOnSameDomainIsRejectedByTheDatabase(): void
{
$this->links->create($this->defaultDomainId, 'einmalig', 'https://www.beispiel.de/a');
$this->expectException(\PDOException::class);
$this->links->create($this->defaultDomainId, 'einmalig', 'https://www.beispiel.de/b');
}
public function testUnknownHostFallsBackToTheDefaultDomain(): void
{
$this->links->create($this->defaultDomainId, 'fallback', 'https://www.beispiel.de/');
// Aufruf per IP-Adresse oder hinter einem Proxy.
$response = $this->controller()->handle(
$this->request('/fallback', host: '203.0.113.10'),
['slug' => 'fallback'],
);
self::assertSame(302, $response->status());
}
public function testHostMatchingIgnoresCaseAndStandardPort(): void
{
$this->links->create($this->defaultDomainId, 'hostcheck', 'https://www.beispiel.de/');
foreach (['short.example.de', 'SHORT.example.de', 'short.example.de:443'] as $host) {
$response = $this->controller()->handle(
$this->request('/hostcheck', host: $host),
['slug' => 'hostcheck'],
);
self::assertSame(302, $response->status(), $host);
}
}
public function testShortUrlUsesTheDomainOfTheLink(): void
{
$qrId = $this->domains->create('https://qr.example.de', null, false);
$id = $this->links->create($qrId, 'druck', 'https://www.beispiel.de/');
$link = $this->links->find($id);
self::assertNotNull($link);
self::assertSame(
'https://qr.example.de/druck',
LinkRepository::shortUrlOf($link, self::BASE_URL),
);
}
public function testDomainWithSubdirectoryBuildsCorrectShortUrl(): void
{
$id = $this->domains->create('https://example.de/kurz', null, false);
$linkId = $this->links->create($id, 'abc', 'https://www.beispiel.de/');
$link = $this->links->find($linkId);
self::assertNotNull($link);
self::assertSame('example.de', $link['domain_host']);
self::assertSame('https://example.de/kurz/abc', LinkRepository::shortUrlOf($link, self::BASE_URL));
}
// --- Statistik und Datenschutz -----------------------------------
public function testClickIsCountedAndVisitLoggedAfterResponse(): void
{
$id = $this->links->create($this->defaultDomainId, 'zaehlen', 'https://www.beispiel.de/');
$request = $this->request(
'/zaehlen',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1',
'https://www.google.de/search?q=test',
);
$response = $this->controller()->handle($request, ['slug' => 'zaehlen']);
// Vor dem Nachlauf darf noch nichts protokolliert sein:
// der Redirect geht zuerst raus.
self::assertSame(0, (int) $this->links->find($id)['click_count']);
self::assertSame(0, $this->visits->totalForLink($id));
$response->runAfterSend();
$link = $this->links->find($id);
self::assertSame(1, (int) $link['click_count']);
self::assertNotNull($link['last_clicked_at']);
self::assertSame(1, $this->visits->totalForLink($id));
$visit = $this->db->fetchOne('SELECT * FROM visits WHERE link_id = ?', [$id]);
self::assertSame('mobile', $visit['device_type']);
self::assertSame('Safari', $visit['browser']);
self::assertSame('iOS', $visit['platform']);
self::assertSame('google.de', $visit['referrer_host']);
}
public function testNoIpIsStoredByDefault(): void
{
$id = $this->links->create($this->defaultDomainId, 'datenschutz', 'https://www.beispiel.de/');
$this->controller()
->handle($this->request('/datenschutz'), ['slug' => 'datenschutz'])
->runAfterSend();
$visit = $this->db->fetchOne('SELECT * FROM visits WHERE link_id = ?', [$id]);
self::assertNull($visit['visitor_hash']);
self::assertArrayNotHasKey('ip', $visit);
self::assertArrayNotHasKey('ip_address', $visit);
}
public function testVisitorHashIsAnonymousWhenEnabled(): void
{
$id = $this->links->create($this->defaultDomainId, 'gehasht', 'https://www.beispiel.de/');
$this->controller(['TRACK_IP' => 'hash', 'IP_HASH_SALT' => 'sehr-geheim'])
->handle($this->request('/gehasht'), ['slug' => 'gehasht'])
->runAfterSend();
$visit = $this->db->fetchOne('SELECT * FROM visits WHERE link_id = ?', [$id]);
self::assertIsString($visit['visitor_hash']);
self::assertSame(32, strlen($visit['visitor_hash']));
self::assertStringNotContainsString('203.0.113', $visit['visitor_hash']);
}
public function testSlugLookupIsCaseInsensitiveAsFallback(): void
{
$this->links->create($this->defaultDomainId, 'grossklein', 'https://www.beispiel.de/');
$response = $this->controller()->handle($this->request('/GROSSKLEIN'), ['slug' => 'GROSSKLEIN']);
self::assertSame(302, $response->status());
}
public function testUmlautTargetIsRedirectedAsPunycode(): void
{
$this->links->create($this->defaultDomainId, 'umlaut', 'https://müller.de/preise');
$response = $this->controller()->handle($this->request('/umlaut'), ['slug' => 'umlaut']);
self::assertSame('https://xn--mller-kva.de/preise', $response->headers()['Location']);
}
public function testDeletingALinkRemovesItsVisits(): void
{
$id = $this->links->create($this->defaultDomainId, 'weg', 'https://www.beispiel.de/');
$this->controller()->handle($this->request('/weg'), ['slug' => 'weg'])->runAfterSend();
self::assertSame(1, $this->visits->totalForLink($id));
$this->links->delete($id);
self::assertNull($this->links->find($id));
self::assertSame(0, $this->visits->totalForLink($id));
}
/**
* @param array<string,string> $overrides
*/
private function controller(array $overrides = []): RedirectController
{
$envFile = tempnam(sys_get_temp_dir(), 'env');
self::assertIsString($envFile);
$values = [
'APP_BASE_URL' => self::BASE_URL,
'DB_DRIVER' => 'sqlite',
'DB_SQLITE_PATH' => ':memory:',
] + $overrides;
$lines = [];
foreach ($values as $key => $value) {
$lines[] = $key . '=' . $value;
}
file_put_contents($envFile, implode("\n", $lines));
$config = Config::load($this->rootDir, $envFile);
unlink($envFile);
return new RedirectController(
$config,
new View($this->rootDir . '/templates'),
$this->links,
$this->visits,
$this->domains,
);
}
private function request(
string $path,
string $userAgent = '',
string $referrer = '',
string $host = 'short.example.de',
): Request {
return new Request(
method: 'GET',
path: $path,
query: [],
post: [],
server: [
'REQUEST_METHOD' => 'GET',
'REQUEST_URI' => $path,
'SCRIPT_NAME' => '/index.php',
'HTTP_HOST' => $host,
'HTTP_USER_AGENT' => $userAgent,
'HTTP_REFERER' => $referrer,
'REMOTE_ADDR' => '203.0.113.42',
],
files: [],
);
}
}