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:
+118
-11
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user