Files
urlshort-qr-gen/tests/MigrationUpgradeTest.php
T
florian.wilde 8a7939bee2 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
2026-08-03 18:11:40 +02:00

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);
}
}