Files
urlshort-qr-gen/tests/MigrationUpgradeTest.php
T
florian.wildeandClaude Opus 5 85b9576b38 test(auth): Gruppenisolation absichern, Doku zum Rechtemodell
AccessControlTest prueft die Trennung ueber die echte Kette
Auth -> AccessPolicy -> Repository, also genau so, wie die
Controller sie benutzen - nicht gegen eine nachgebaute Logik.
Abgedeckt sind unter anderem: ein Mitglied sieht nur die eigene
Gruppe, der direkte Aufruf einer fremden Kennung findet nichts,
ein Benutzer ohne Gruppe sieht gar nichts (die leere Liste darf
nicht wie "keine Einschraenkung" wirken), Viewer duerfen lesen
aber nicht schreiben, das Umhaengen einer Domain nimmt ihre
Kurzlinks mit, fremde Logos werden nicht ausgeliefert, ein
gesperrtes Konto verliert seine laufende Sitzung und ein
Passwortwechsel macht bestehende Sitzungen ungueltig.

MigrationUpgradeTest deckt zusaetzlich den Umstieg ab: das
Bestandskonto wird mit unveraendertem Hash uebernommen, die alten
settings-Schluessel verschwinden, Bestandsdomains landen in der
Standardgruppe - und eine frische Installation bekommt kein
erfundenes Konto.

README: neuer Abschnitt "Benutzer, Gruppen und Rechte" mit dem
Grundprinzip, den beiden Rollenebenen, der Einrichtung in drei
Schritten und dem Umstieg von der Einbenutzer-Version.
ADMIN_USER und ADMIN_PASSWORD_HASH sind als Notfallzugang neu
beschrieben, in README und .env.example.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 12:03:41 +02:00

283 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests;
use App\Database;
use App\Migrator;
use App\Repository\DomainRepository;
use App\Repository\GroupRepository;
use App\Repository\LinkRepository;
use App\Repository\SettingRepository;
use App\Repository\UserRepository;
use App\Security\Role;
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
{
/** Verzeichnis mit nur der ersten Migration (Stand vor den Domains). */
private string $partialDir;
/** Verzeichnis mit den ersten beiden Migrationen (Stand vor den Gruppen). */
private string $domainsDir;
protected function setUp(): void
{
$source = dirname(__DIR__) . '/migrations';
$base = sys_get_temp_dir() . '/urlshort-mig-' . bin2hex(random_bytes(4));
$this->partialDir = $base . '-1';
$this->domainsDir = $base . '-2';
foreach ([$this->partialDir, $this->domainsDir] as $dir) {
mkdir($dir, 0775, true);
copy($source . '/001_initial.php', $dir . '/001_initial.php');
}
copy($source . '/002_domains.php', $this->domainsDir . '/002_domains.php');
}
protected function tearDown(): void
{
foreach ([$this->partialDir, $this->domainsDir] as $dir) {
foreach (glob($dir . '/*') ?: [] as $file) {
unlink($file);
}
if (is_dir($dir)) {
rmdir($dir);
}
}
}
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', '003_users_groups'],
$applied,
'Nur die noch offenen Migrationen duerfen 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');
}
/**
* Das bisherige Einzelkonto steckte in der settings-Tabelle. Nach dem
* Update muss es als vollwertiger Administrator in "users" stehen -
* sonst waere die Installation nach dem Update ausgesperrt.
*/
public function testUpgradeAdoptsTheSingleAdminAccountFromSettings(): void
{
$db = Database::sqlite(':memory:');
(new Migrator($db, $this->partialDir))->migrate();
$hash = password_hash('ein-langes-passwort', PASSWORD_DEFAULT);
$settings = new SettingRepository($db);
$settings->set('admin_user', 'florian');
$settings->set('admin_password_hash', $hash);
(new Migrator($db, dirname(__DIR__) . '/migrations'))->migrate();
$users = new UserRepository($db);
$user = $users->findByUsername('florian');
self::assertNotNull($user, 'Das Bestandskonto muss uebernommen werden.');
self::assertSame(Role::ADMIN, $user['role']);
self::assertSame(1, (int) $user['is_active']);
self::assertTrue(
password_verify('ein-langes-passwort', (string) $user['password_hash']),
'Der bestehende Hash muss unveraendert uebernommen werden - sonst aendert sich das Passwort.',
);
// Die alten Schluessel duerfen nicht als tote Zugangsdaten zurueckbleiben.
self::assertNull($settings->get('admin_user'));
self::assertNull($settings->get('admin_password_hash'));
// Und er ist Editor der Standardgruppe, kann also selbst Links anlegen.
$groups = new GroupRepository($db);
self::assertSame(
Role::EDITOR,
$groups->roleInGroup((int) $user['id'], $groups->defaultGroupId()),
);
}
/**
* Eine frische Installation hat noch kein Konto - dann darf die
* Migration auch keines erfinden.
*/
public function testUpgradeCreatesNoAccountWhenThereWasNone(): void
{
$db = Database::sqlite(':memory:');
(new Migrator($db, dirname(__DIR__) . '/migrations'))->migrate();
self::assertSame(0, (new UserRepository($db))->count());
self::assertFalse((new UserRepository($db))->hasAnyActive());
}
/**
* Bestandsdomains muessen in einer Gruppe landen, sonst waeren ihre
* Kurzlinks fuer alle ausser Administratoren unsichtbar.
*/
public function testUpgradePutsExistingDomainsIntoTheDefaultGroup(): void
{
$db = Database::sqlite(':memory:');
// Stand vor den Gruppen: erste und zweite Migration, dann eine
// Domain wie sie eine laufende Installation haette.
(new Migrator($db, $this->domainsDir))->migrate();
$db->execute(
'INSERT INTO domains (host, base_url, label, is_default, created_at) VALUES (?, ?, NULL, 1, ?)',
['short.example.de', 'https://short.example.de', gmdate('Y-m-d H:i:s')],
);
(new Migrator($db, dirname(__DIR__) . '/migrations'))->migrate();
$groups = new GroupRepository($db);
$defaultId = $groups->defaultGroupId();
$groupId = (int) $db->fetchValue('SELECT group_id FROM domains WHERE host = ?', ['short.example.de']);
self::assertSame($defaultId, $groupId);
self::assertSame(GroupRepository::DEFAULT_NAME, $groups->find($defaultId)['name']);
}
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',
);
$expected = [
'links', 'visits', 'qr_designs', 'settings', 'login_attempts', 'domains', 'migrations',
'users', 'user_groups', 'group_members',
];
foreach ($expected 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);
}
}