Wer bereits gedruckte QR-Codes aus einem anderen System uebernimmt, kann deren Adressen nicht mehr aendern. Sehen die aus wie "/g/1AEJ0X90ZmF", waren sie hier bisher nicht abbildbar: der Normalisierer machte daraus "g-1aej0x90zmf" - Schraegstrich zu Bindestrich, alles klein. Der Kurzlink zeigte damit ins Leere. Ein Slug darf jetzt mehrere durch "/" getrennte Abschnitte haben (hoechstens acht, je 64 Zeichen) und behaelt die eingegebene Schreibweise. Erlaubt sind Buchstaben, Ziffern, Bindestrich, Unterstrich und Punkt; jeder Abschnitt muss mit einem Buchstaben oder einer Ziffer beginnen und enden. Umlaute und Leerzeichen werden weiterhin umgeschrieben. Wird versehentlich eine komplette URL eingefuegt, bleibt davon nur der Pfad uebrig. Damit die Verwaltung nicht verdeckt werden kann, greift die Liste reservierter Namen jetzt auf den ERSTEN Abschnitt: "admin/geheim" wird ebenso abgelehnt wie "admin". Die Route der Weiterleitung steht weiterhin ganz am Ende, alle Verwaltungsrouten gewinnen davor. Bei der Eindeutigkeit gilt bewusst das Gegenteil: sie wird OHNE Ruecksicht auf Gross- und Kleinschreibung geprueft. "Messe" und "messe" koennen auf derselben Domain nicht nebeneinander bestehen - an einer gedruckten Adresse waere sonst nicht mehr erkennbar, welcher Link gemeint ist. Ausserdem wuerde MySQL sie je nach Kollation ohnehin als gleich ansehen, SQLite nicht; so verhalten sich beide Treiber wieder gleich. Beim Aufruf wird zuerst exakt gesucht und nur bei einem Fehlschlag ohne Ruecksicht auf die Schreibweise - abgetippte Codes stimmen nicht immer. Weil die Eindeutigkeit oben schon ohne Ruecksicht geprueft wurde, kann dieser zweite Versuch nie mehrdeutig sein. Geprueft in beiden Betriebsarten: im Container und in der Shared-Hosting-Variante, bei der die Root-.htaccess nach public/ umleitet - dort sind mehrteilige Pfade der wahrscheinlichste Stolperstein.
497 lines
18 KiB
PHP
497 lines
18 KiB
PHP
<?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());
|
|
}
|
|
|
|
// --- Mehrteilige Slugs aus fremden Systemen -----------------------
|
|
|
|
public function testPathSlugIsStoredAndRedirected(): void
|
|
{
|
|
$this->links->create($this->defaultDomainId, 'g/1AEJ0X90ZmF', 'https://www.beispiel.de/uebernommen');
|
|
|
|
$response = $this->controller()->handle(
|
|
$this->request('/g/1AEJ0X90ZmF'),
|
|
['slug' => 'g/1AEJ0X90ZmF'],
|
|
);
|
|
|
|
self::assertSame(302, $response->status());
|
|
self::assertSame('https://www.beispiel.de/uebernommen', $response->headers()['Location']);
|
|
}
|
|
|
|
public function testLetterCaseIsPreservedExactly(): void
|
|
{
|
|
$id = $this->links->create($this->defaultDomainId, 'g/1AEJ0X90ZmF', 'https://www.beispiel.de/');
|
|
|
|
self::assertSame('g/1AEJ0X90ZmF', $this->links->find($id)['slug']);
|
|
}
|
|
|
|
public function testPathSlugAlsoWorksWithWrongLetterCase(): void
|
|
{
|
|
// Abgetippte Codes stimmen nicht immer in der Schreibweise.
|
|
$this->links->create($this->defaultDomainId, 'g/1AEJ0X90ZmF', 'https://www.beispiel.de/');
|
|
|
|
$response = $this->controller()->handle(
|
|
$this->request('/g/1aej0x90zmf'),
|
|
['slug' => 'g/1aej0x90zmf'],
|
|
);
|
|
|
|
self::assertSame(302, $response->status());
|
|
}
|
|
|
|
public function testSlugsDifferingOnlyInCaseAreConsideredTaken(): void
|
|
{
|
|
$this->links->create($this->defaultDomainId, 'Messe', 'https://www.beispiel.de/a');
|
|
|
|
// Sonst waere an einer gedruckten Adresse nicht mehr erkennbar,
|
|
// welcher Link gemeint ist.
|
|
self::assertTrue($this->links->slugExists('messe', $this->defaultDomainId));
|
|
self::assertTrue($this->links->slugExists('MESSE', $this->defaultDomainId));
|
|
self::assertTrue($this->links->slugExists('Messe', $this->defaultDomainId));
|
|
self::assertFalse($this->links->slugExists('messe2', $this->defaultDomainId));
|
|
}
|
|
|
|
public function testPathSlugsAreUniquePerDomain(): void
|
|
{
|
|
$qrId = $this->domains->create('https://qr.example.de', null, false);
|
|
|
|
$this->links->create($this->defaultDomainId, 'g/ABC', 'https://www.beispiel.de/a');
|
|
|
|
self::assertTrue($this->links->slugExists('g/ABC', $this->defaultDomainId));
|
|
self::assertFalse($this->links->slugExists('g/ABC', $qrId));
|
|
|
|
$this->links->create($qrId, 'g/ABC', 'https://www.beispiel.de/b');
|
|
|
|
$viaQr = $this->controller()->handle(
|
|
$this->request('/g/ABC', host: 'qr.example.de'),
|
|
['slug' => 'g/ABC'],
|
|
);
|
|
|
|
self::assertSame('https://www.beispiel.de/b', $viaQr->headers()['Location']);
|
|
}
|
|
|
|
public function testUnknownPathSlugGivesFriendly404(): void
|
|
{
|
|
$response = $this->controller()->handle(
|
|
$this->request('/g/gibtesnicht'),
|
|
['slug' => 'g/gibtesnicht'],
|
|
);
|
|
|
|
self::assertSame(404, $response->status());
|
|
self::assertStringContainsString('g/gibtesnicht', $response->body());
|
|
}
|
|
|
|
public function testClicksAreCountedForPathSlugs(): void
|
|
{
|
|
$id = $this->links->create($this->defaultDomainId, 'g/1AEJ0X90ZmF', 'https://www.beispiel.de/');
|
|
|
|
$this->controller()
|
|
->handle($this->request('/g/1AEJ0X90ZmF'), ['slug' => 'g/1AEJ0X90ZmF'])
|
|
->runAfterSend();
|
|
|
|
self::assertSame(1, (int) $this->links->find($id)['click_count']);
|
|
self::assertSame(1, $this->visits->totalForLink($id));
|
|
}
|
|
|
|
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: [],
|
|
);
|
|
}
|
|
}
|