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>
This commit is contained in:
2026-08-14 12:03:41 +02:00
co-authored by Claude Opus 5
parent 7f52613821
commit 85b9576b38
4 changed files with 609 additions and 30 deletions
+118 -11
View File
@@ -7,7 +7,11 @@ 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;
/**
@@ -17,23 +21,37 @@ use PHPUnit\Framework\TestCase;
*/
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
{
// 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');
$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 (glob($this->partialDir . '/*') ?: [] as $file) {
unlink($file);
}
if (is_dir($this->partialDir)) {
rmdir($this->partialDir);
foreach ([$this->partialDir, $this->domainsDir] as $dir) {
foreach (glob($dir . '/*') ?: [] as $file) {
unlink($file);
}
if (is_dir($dir)) {
rmdir($dir);
}
}
}
@@ -74,7 +92,11 @@ final class MigrationUpgradeTest extends TestCase
// --- Update ---------------------------------------------------
$applied = (new Migrator($db, dirname(__DIR__) . '/migrations'))->migrate();
self::assertSame(['002_domains'], $applied, 'Nur die neue Migration darf laufen.');
self::assertSame(
['002_domains', '003_users_groups'],
$applied,
'Nur die noch offenen Migrationen duerfen laufen.',
);
// --- Bestand pruefen ------------------------------------------
$links = new LinkRepository($db);
@@ -146,6 +168,86 @@ final class MigrationUpgradeTest extends TestCase
$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:');
@@ -157,7 +259,12 @@ final class MigrationUpgradeTest extends TestCase
'name',
);
foreach (['links', 'visits', 'qr_designs', 'settings', 'login_attempts', 'domains', 'migrations'] as $table) {
$expected = [
'links', 'visits', 'qr_designs', 'settings', 'login_attempts', 'domains', 'migrations',
'users', 'user_groups', 'group_members',
];
foreach ($expected as $table) {
self::assertContains($table, $tables);
}