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
176 lines
6.8 KiB
PHP
176 lines
6.8 KiB
PHP
<?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);
|
|
}
|
|
}
|