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,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: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user