diff --git a/.env.example b/.env.example index 467e020..365eb14 100644 --- a/.env.example +++ b/.env.example @@ -37,13 +37,22 @@ DB_USER=urlshort DB_PASSWORD= DB_CHARSET=utf8mb4 -# --- Admin-Zugang --------------------------------------------- -ADMIN_USER=admin -# bcrypt-Hash des Passworts. Leer lassen -> beim ersten Aufruf startet -# der Setup-Assistent. Dieser verlangt zusaetzlich ein Einmal-Token, -# das in data/setup-token.txt steht (und im Serverprotokoll), damit -# niemand anderes die frische Installation uebernehmen kann. +# --- Notfallzugang -------------------------------------------- +# Die regulaeren Konten stehen in der Datenbank und werden unter +# "Benutzer" verwaltet. Das erste Konto legt der Setup-Assistent beim +# ersten Aufruf an; er verlangt zusaetzlich ein Einmal-Token aus +# data/setup-token.txt (und dem Serverprotokoll), damit niemand anderes +# die frische Installation uebernimmt. +# +# Die beiden folgenden Zeilen sind davon unabhaengig: sie beschreiben +# einen zusaetzlichen Zugang mit Administratorrechten, der nur hier +# steht. Er taucht in der Benutzerverwaltung nicht auf und laesst sich +# dort auch nicht aendern - gedacht fuer den Fall, dass das letzte +# Administratorkonto gesperrt wurde oder sein Passwort verloren ging. +# Wird er nicht gebraucht, beide Zeilen leer lassen oder entfernen. +# # Hash erzeugen: php -r "echo password_hash('geheim', PASSWORD_DEFAULT), PHP_EOL;" +ADMIN_USER=admin ADMIN_PASSWORD_HASH= # --- Sessions / Sicherheit ------------------------------------ diff --git a/README.md b/README.md index 29aaad0..cb8a8ac 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ keine Node-Toolchain – nur PHP, eine Datenbank und ein Webserver. Weiterleitungen `/{slug}` – Übersicht, Verwaltung, Statistiken und QR-Codes verlangen Benutzername und Passwort. +**Mehrere Benutzer, in Gruppen organisiert.** Eine Domain gehört zu genau einer Gruppe, +jeder Kurzlink zu seiner Domain. Wer Mitglied einer Gruppe ist, sieht deren Kurzlinks – +als *Editor* darf er sie auch anlegen und ändern, als *Viewer* nur ansehen. + --- ## Inhalt @@ -18,6 +22,7 @@ Benutzername und Passwort. - [Deployment B: Docker](#deployment-b-docker) - [Konfigurationsreferenz](#konfigurationsreferenz) - [Bedienung](#bedienung) +- [Benutzer, Gruppen und Rechte](#benutzer-gruppen-und-rechte) - [Projektstruktur](#projektstruktur) - [Sicherheit und Datenschutz](#sicherheit-und-datenschutz) - [Tests](#tests) @@ -60,9 +65,19 @@ Benutzername und Passwort. - Download als **PNG** (Web) und **SVG** (vektorbasiert für den Druck) - Live-Vorschau im Browser beim Anpassen +**Benutzer und Rechte** +- Beliebig viele Konten, verwaltet im Browser – kein Eingriff in Dateien nötig +- **Gruppen** bündeln Benutzer und Domains: Mitglieder sehen die Kurzlinks ihrer Gruppen, + sonst nichts – auch nicht über eine geratene Adresse +- Zwei Rollenebenen: global `Administrator` (Benutzer, Gruppen, Domains, sieht alles) + bzw. `Benutzer`, innerhalb einer Gruppe `Editor` (anlegen und ändern) bzw. `Viewer` (nur lesen) +- Jeder ändert sein Passwort selbst; eine Änderung beendet alle anderen Sitzungen +- Notfallzugang über die `.env`, falls das letzte Administratorkonto einmal aussperrt + **Sicherheit** -- Setup-Assistent beim ersten Start oder feste Zugangsdaten in der `.env` -- Session-Login mit bcrypt-Hash, CSRF-Token für alle Formulare, Rate-Limiting am Login +- Setup-Assistent beim ersten Start; Notfallzugang optional in der `.env` +- Session-Login mit bcrypt-Hash, CSRF-Token für alle Formulare +- Rate-Limiting am Login nach IP-Adresse **und** Benutzername - Vorbereitete Statements überall, Output-Escaping, Security-Header inkl. CSP --- @@ -315,14 +330,24 @@ Datei funktioniert in der Praxis ebenfalls, sofern gerade nicht geschrieben wird Das Schema wird beim ersten Request automatisch angelegt (`migrations/`), ein manueller Migrationsschritt entfällt. -### Admin-Zugang +### Notfallzugang + +Die regulären Konten stehen in der Datenbank und werden unter **Benutzer** verwaltet; +das erste legt der Setup-Assistent an. Die beiden folgenden Schlüssel beschreiben +davon unabhängig einen **zusätzlichen** Zugang mit Administratorrechten: | Schlüssel | Standard | Bedeutung | |---|---|---| -| `ADMIN_USER` | `admin` | Benutzername | -| `ADMIN_PASSWORD_HASH` | *(leer)* | bcrypt-Hash. **Leer** → beim ersten Aufruf startet der Setup-Assistent und speichert die Zugangsdaten in der Datenbank. | +| `ADMIN_USER` | `admin` | Benutzername des Notfallzugangs | +| `ADMIN_PASSWORD_HASH` | *(leer)* | bcrypt-Hash dazu. **Leer** → es gibt keinen Notfallzugang. | -Hash selbst erzeugen: +Dieses Konto taucht in der Benutzerverwaltung nicht auf und lässt sich dort auch nicht +ändern – es existiert nur in der `.env`. Gedacht ist es für den Fall, dass das letzte +Administratorkonto gesperrt wurde oder sein Passwort verloren ging. Ein reguläres Konto +mit demselben Namen gewinnt bei der Anmeldung; die Benutzerverwaltung verhindert deshalb, +dass der Name doppelt vergeben wird. + +Hash erzeugen: ```bash php -r "echo password_hash('dein-passwort', PASSWORD_DEFAULT), PHP_EOL;" @@ -330,8 +355,7 @@ php -r "echo password_hash('dein-passwort', PASSWORD_DEFAULT), PHP_EOL;" docker compose exec app php -r "echo password_hash('dein-passwort', PASSWORD_DEFAULT), PHP_EOL;" ``` -Ist `ADMIN_PASSWORD_HASH` gesetzt, gewinnt dieser Wert gegenüber der Datenbank – -das ist gleichzeitig der Weg, ein vergessenes Passwort zurückzusetzen. +Wird der Zugang nicht mehr gebraucht, beide Zeilen leeren oder entfernen. ### Sessions und Login-Schutz @@ -372,8 +396,12 @@ das ist gleichzeitig der Weg, ein vergessenes Passwort zurückzusetzen. ### Anmelden -Beim ersten Aufruf erscheint der Setup-Assistent (sofern kein -`ADMIN_PASSWORD_HASH` gesetzt ist). Danach führt jeder Aufruf zur Anmeldemaske. +Beim ersten Aufruf erscheint der Setup-Assistent (sofern noch kein Konto existiert und +kein `ADMIN_PASSWORD_HASH` gesetzt ist). Das dort angelegte Konto ist Administrator. +Danach führt jeder Aufruf zur Anmeldemaske. + +Das eigene Passwort ändert man über den **Benutzernamen oben rechts → Mein Konto**. +Dort steht auch, in welchen Gruppen man mit welcher Rolle Mitglied ist. ### Kurzlink anlegen @@ -606,6 +634,89 @@ Zahlen `302` verwenden. --- +## Benutzer, Gruppen und Rechte + +### Das Grundprinzip + +Der Zugriff hängt an einer einzigen Kette: + +``` +Benutzer → Gruppe → Domain → Kurzlink +``` + +Ein Benutzer ist Mitglied in beliebig vielen **Gruppen**. Jede **Domain** gehört zu genau +einer Gruppe, jeder **Kurzlink** zu seiner Domain. Wer Mitglied einer Gruppe ist, sieht +damit alle Kurzlinks aller Domains dieser Gruppe – und sonst nichts. + +Ein Kurzlink hat also keinen persönlichen Eigentümer. Das ist Absicht: verlässt jemand +das Unternehmen, bleiben die Links seiner Gruppe erreichbar, und gedruckte QR-Codes +funktionieren weiter. Das Löschen eines Kontos entfernt deshalb keine Kurzlinks. + +### Die beiden Rollenebenen + +**Global** (bei jedem Konto unter *Benutzer*): + +| Rolle | Darf | +|---|---| +| `Administrator` | alles: Benutzer, Gruppen und Domains verwalten, sieht **sämtliche** Kurzlinks unabhängig von Gruppen | +| `Benutzer` | nur das, was die Gruppenmitgliedschaft hergibt | + +**Innerhalb einer Gruppe** (je Mitgliedschaft): + +| Rolle | Darf | +|---|---| +| `Editor` | Kurzlinks der Gruppe anlegen, ändern, deaktivieren, löschen und QR-Codes gestalten | +| `Viewer` | Kurzlinks ansehen, Statistik lesen, QR-Codes herunterladen – aber nichts speichern | + +Ein Administrator braucht keine Mitgliedschaft, um Kurzlinks zu *sehen*. Damit er auch +selbst welche **anlegen** kann, muss er in der betreffenden Gruppe Editor sein – deshalb +trägt der Setup-Assistent das erste Konto gleich als Editor der Gruppe „Standard“ ein. + +### Einrichten in drei Schritten + +1. **Gruppen → Neue Gruppe** – z. B. „Marketing“. +2. **Domains → Domain bearbeiten** – Feld *Gruppe* auf „Marketing“ stellen. Alle Kurzlinks + dieser Domain gehören ab sofort der Gruppe. Wird eine Domain später umgehängt, wandern + ihre Kurzlinks mit. +3. **Benutzer → Neuer Benutzer** – Konto anlegen und in der Tabelle *Gruppen* die Rolle + je Gruppe setzen (`kein Mitglied` / `Editor` / `Viewer`). + +Mitglieder lassen sich auch von der anderen Seite pflegen: **Gruppen → Bearbeiten** zeigt +alle Mitglieder samt Rolle und listet die Domains der Gruppe. + +### Was passiert, wenn … + +| Fall | Verhalten | +|---|---| +| Ein Benutzer ruft die Adresse eines fremden Kurzlinks direkt auf | **404** – die Existenz wird nicht verraten | +| Ein *Viewer* öffnet das Bearbeitungsformular | **403** mit Hinweis auf die fehlende Editor-Rolle | +| Ein Benutzer ohne Gruppe meldet sich an | Übersicht und Liste bleiben leer, mit erklärendem Hinweis | +| Ein Konto wird gesperrt | Laufende Sitzungen enden beim nächsten Aufruf sofort | +| Ein Passwort wird geändert | Alle anderen Sitzungen dieses Kontos werden ungültig | +| Das letzte aktive Administratorkonto soll herabgestuft, gesperrt oder gelöscht werden | Wird abgelehnt | +| Eine Gruppe mit zugeordneten Domains soll gelöscht werden | Wird abgelehnt – erst die Domains umhängen | + +Auch hochgeladene **Logos** sind an die Gruppe gebunden: `/admin/uploads/` liefert +nur aus, was zu einem Kurzlink aus einer Gruppe des Aufrufers gehört. Der Dateiname allein +öffnet nichts. + +### Umstieg von der Einbenutzer-Version + +Beim ersten Aufruf nach dem Update läuft die Migration automatisch: + +- Das bisherige Konto wird zu **Benutzer Nr. 1** mit der Rolle `Administrator`. + Benutzername und Passwort bleiben unverändert – der vorhandene Hash wird übernommen. +- Es entsteht die Gruppe **„Standard“**; alle vorhandenen Domains landen darin, + das übernommene Konto wird ihr Editor. +- Die alten Schlüssel `admin_user` und `admin_password_hash` werden aus der + `settings`-Tabelle entfernt, damit keine veralteten Zugangsdaten zurückbleiben. + +Ein in der `.env` hinterlegter `ADMIN_PASSWORD_HASH` bleibt gültig, wird aber ab jetzt +als [Notfallzugang](#notfallzugang) behandelt: er steht neben den regulären Konten, +statt sie zu überstimmen. + +--- + ## Projektstruktur ``` @@ -621,12 +732,14 @@ urlshort-qr-gen/ │ ├── Migrator.php ← führt migrations/ automatisch aus │ ├── View.php ← minimale Template-Engine │ ├── Http/ ← Request, Response, Router, BasePath -│ ├── Controller/ ← Setup, Auth, Dashboard, Link, Domain, Stats, Qr, Redirect +│ ├── Controller/ ← Setup, Auth, Dashboard, Link, Domain, Stats, Qr, +│ │ User, Group, Profile, Redirect │ ├── Repository/ ← Datenzugriff (vorbereitete Statements) -│ ├── Security/ ← Session, Auth, CSRF, Rate-Limiting +│ ├── Security/ ← Session, Auth, AccessPolicy, Role, CSRF, Rate-Limiting │ ├── Service/ ← DomainChecker (DNS- und Zuordnungsprüfung) │ ├── Qr/ ← QrOptions, QrRenderer, LogoStore -│ └── Support/ ← Slug, UrlValidator, Punycode, ShortDomain, UserAgentParser, Color +│ └── Support/ ← Slug, UrlValidator, Punycode, ShortDomain, +│ UserAgentParser, Color, Password, Username ├── templates/ ← serverseitig gerenderte Seiten ├── migrations/ ← Schema je Treiber ├── data/ ← SQLite-Datei + hochgeladene Logos (beschreibbar!) diff --git a/tests/AccessControlTest.php b/tests/AccessControlTest.php new file mode 100644 index 0000000..e51a72b --- /dev/null +++ b/tests/AccessControlTest.php @@ -0,0 +1,350 @@ + AccessPolicy -> + * Repository, also genau so, wie die Controller sie benutzen. Ein + * falsch gebauter Filter faellt damit auf, egal an welcher Stelle er + * steckt. + */ +final class AccessControlTest extends TestCase +{ + private Database $db; + private Config $config; + private LinkRepository $links; + private DomainRepository $domains; + private GroupRepository $groups; + private UserRepository $users; + + /** @var array */ + private array $ids = []; + + protected function setUp(): void + { + $_SESSION = []; + + // Bewusst ohne .env-Datei: sonst koennte ein dort hinterlegter + // ADMIN_PASSWORD_HASH den Notfallzugang in die Tests tragen. + $this->config = Config::load(dirname(__DIR__), dirname(__DIR__) . '/.env.does-not-exist'); + + $this->db = Database::sqlite(':memory:'); + (new Migrator($this->db, dirname(__DIR__) . '/migrations'))->migrate(); + + $this->groups = new GroupRepository($this->db); + $this->users = new UserRepository($this->db); + $this->domains = new DomainRepository($this->db, $this->groups); + $this->links = new LinkRepository($this->db); + + // Zwei Gruppen mit je einer eigenen Domain und einem Kurzlink. + $marketing = $this->groups->create('Marketing'); + $vertrieb = $this->groups->create('Vertrieb'); + + $marketingDomain = $this->domains->create('https://qr.example.de', null, true, $marketing); + $vertriebDomain = $this->domains->create('https://short.example.de', null, false, $vertrieb); + + $this->ids = [ + 'marketing' => $marketing, + 'vertrieb' => $vertrieb, + 'marketingDomain' => $marketingDomain, + 'vertriebDomain' => $vertriebDomain, + 'marketingLink' => $this->links->create($marketingDomain, 'messe', 'https://example.de/messe'), + 'vertriebLink' => $this->links->create($vertriebDomain, 'angebot', 'https://example.de/angebot'), + ]; + } + + protected function tearDown(): void + { + $_SESSION = []; + } + + // --- Sichtbarkeit ---------------------------------------------------- + + public function testAMemberSeesOnlyTheLinksOfTheirOwnGroup(): void + { + $policy = $this->policyForMember('anna', $this->ids['marketing'], Role::EDITOR); + $visible = $policy->visibleDomainIds(); + + $links = $this->links->all('', 'created_desc', 200, 0, null, $visible); + self::assertSame(['messe'], array_column($links, 'slug')); + + self::assertSame(1, $this->links->count('', null, $visible)); + self::assertSame(1, $this->links->summary(null, $visible)['links']); + } + + public function testDirectAccessToAForeignLinkFindsNothing(): void + { + $policy = $this->policyForMember('anna', $this->ids['marketing'], Role::EDITOR); + $visible = $policy->visibleDomainIds(); + + // Der eigene Link wird gefunden ... + self::assertNotNull($this->links->find($this->ids['marketingLink'], $visible)); + + // ... der fremde nicht, obwohl seine Kennung bekannt ist. + self::assertNull($this->links->find($this->ids['vertriebLink'], $visible)); + + $foreign = $this->links->find($this->ids['vertriebLink'], null); + self::assertNotNull($foreign); + self::assertFalse($policy->canViewLink($foreign)); + self::assertFalse($policy->canEditLink($foreign)); + } + + public function testAUserWithoutAnyGroupSeesNothingAtAll(): void + { + $policy = $this->policyFor($this->login('niemand')); + $visible = $policy->visibleDomainIds(); + + self::assertSame([], $visible, 'Ohne Gruppe darf keine Domain sichtbar sein.'); + self::assertFalse($policy->canCreateLinks()); + + // Die leere Liste bedeutet "nichts" - und darf gerade nicht wie + // "keine Einschraenkung" wirken. + self::assertSame([], $this->links->all('', 'created_desc', 200, 0, null, $visible)); + self::assertSame(0, $this->links->count('', null, $visible)); + self::assertNull($this->links->find($this->ids['marketingLink'], $visible)); + self::assertSame(0, $this->links->summary(null, $visible)['links']); + } + + public function testMembershipInBothGroupsShowsEverything(): void + { + $chef = $this->login('chef'); + $this->groups->setMember($this->ids['marketing'], $chef, Role::VIEWER); + $this->groups->setMember($this->ids['vertrieb'], $chef, Role::EDITOR); + + $links = $this->links->all('', 'slug_asc', 200, 0, null, $this->policyFor($chef)->visibleDomainIds()); + + self::assertSame(['angebot', 'messe'], array_column($links, 'slug')); + } + + public function testAnAdminIsNotRestrictedAtAll(): void + { + $policy = $this->policyFor($this->login('chefin', Role::ADMIN)); + + self::assertTrue($policy->isAdmin()); + self::assertNull($policy->visibleDomainIds(), 'null bedeutet: keine Einschraenkung.'); + self::assertNull($policy->editableDomainIds()); + + self::assertCount(2, $this->links->all('', 'created_desc', 200, 0, null, $policy->visibleDomainIds())); + self::assertNotNull($this->links->find($this->ids['vertriebLink'], $policy->visibleDomainIds())); + } + + // --- Schreibrechte --------------------------------------------------- + + public function testAViewerMaySeeButNotEdit(): void + { + $policy = $this->policyForMember('lisa', $this->ids['marketing'], Role::VIEWER); + + self::assertSame([$this->ids['marketingDomain']], $policy->visibleDomainIds()); + self::assertSame([], $policy->editableDomainIds(), 'Ein Viewer darf nirgends schreiben.'); + self::assertFalse($policy->canCreateLinks()); + + $link = $this->links->find($this->ids['marketingLink'], null); + self::assertNotNull($link); + self::assertTrue($policy->canViewLink($link)); + self::assertFalse($policy->canEditLink($link)); + } + + public function testAnEditorMayEditOnlyInTheGroupsWhereTheyAreEditor(): void + { + $chef = $this->login('chef'); + $this->groups->setMember($this->ids['marketing'], $chef, Role::VIEWER); + $this->groups->setMember($this->ids['vertrieb'], $chef, Role::EDITOR); + + $policy = $this->policyFor($chef); + + self::assertSame([$this->ids['vertriebDomain']], $policy->editableDomainIds()); + self::assertTrue($policy->canCreateLinks()); + } + + // --- Verschieben einer Domain --------------------------------------- + + public function testMovingADomainMovesItsLinksToTheOtherGroup(): void + { + $anna = $this->login('anna'); + $this->groups->setMember($this->ids['marketing'], $anna, Role::EDITOR); + + // Vorher: Anna sieht "messe". + self::assertNotNull($this->links->find( + $this->ids['marketingLink'], + $this->policyFor($anna)->visibleDomainIds(), + )); + + // Der Administrator haengt die Domain in die andere Gruppe um. + $this->domains->update( + $this->ids['marketingDomain'], + 'https://qr.example.de', + null, + $this->ids['vertrieb'], + ); + + // Nachher nicht mehr - der Kurzlink ist mitgewandert. + self::assertNull($this->links->find( + $this->ids['marketingLink'], + $this->policyFor($anna)->visibleDomainIds(), + )); + } + + // --- Anmeldung ------------------------------------------------------- + + public function testALockedAccountLosesItsRunningSession(): void + { + $anna = $this->login('anna'); + $auth = $this->authFor($anna); + + self::assertTrue($auth->check()); + + $this->users->update($anna, 'anna', Role::USER, false); + + self::assertFalse($this->newAuth()->check(), 'Ein gesperrtes Konto darf sofort ausgesperrt sein.'); + self::assertNull($this->newAuth()->verifyCredentials('anna', 'ein-langes-passwort')); + } + + public function testChangingThePasswordInvalidatesExistingSessions(): void + { + $anna = $this->login('anna'); + self::assertTrue($this->newAuth()->check()); + + $this->users->updatePassword($anna, 'ein-anderes-langes-passwort'); + + self::assertFalse($this->newAuth()->check()); + } + + public function testUsernamesAreComparedWithoutRegardToCase(): void + { + $this->users->create('Anna', 'ein-langes-passwort'); + $auth = $this->newAuth(); + + self::assertNotNull($auth->verifyCredentials('anna', 'ein-langes-passwort')); + self::assertNull($auth->verifyCredentials('anna', 'falsches-passwort')); + self::assertTrue($this->users->usernameExists('ANNA')); + } + + public function testWithoutAnEnvAccountThereIsNoEmergencyLogin(): void + { + self::assertNull($this->newAuth()->emergencyAccount()); + } + + // --- Logos ----------------------------------------------------------- + + public function testAForeignLogoIsNotServed(): void + { + $designs = new QrDesignRepository($this->db); + $options = $designs->forLink($this->ids['vertriebLink']); + $designs->save($this->ids['vertriebLink'], $options->withLogoPath('logo-2-abcdef123456.png')); + + // Der Dateiname allein reicht nicht: das Logo gehoert zu einem + // Kurzlink aus einer fremden Gruppe. + $anna = $this->policyForMember('anna', $this->ids['marketing'], Role::EDITOR); + self::assertFalse($designs->logoIsVisible('logo-2-abcdef123456.png', $anna->visibleDomainIds())); + + // Fuer ein Mitglied der Gruppe dagegen schon - auch als Viewer. + $_SESSION = []; + $bert = $this->policyForMember('bert', $this->ids['vertrieb'], Role::VIEWER); + self::assertTrue($designs->logoIsVisible('logo-2-abcdef123456.png', $bert->visibleDomainIds())); + + // Und ein Administrator sieht es ohnehin. + self::assertTrue($designs->logoIsVisible('logo-2-abcdef123456.png', null)); + } + + // --- Statistik ------------------------------------------------------- + + public function testTheDashboardChartCountsOnlyVisibleClicks(): void + { + $visits = new VisitRepository($this->db); + $today = gmdate('Y-m-d H:i:s'); + + foreach ([$this->ids['marketingLink'], $this->ids['vertriebLink'], $this->ids['vertriebLink']] as $linkId) { + $this->db->execute( + 'INSERT INTO visits (link_id, visited_at, device_type, browser, platform) + VALUES (?, ?, ?, ?, ?)', + [$linkId, $today, 'desktop', 'Firefox', 'Linux'], + ); + } + + $sumOf = static fn (array $series): int => array_sum(array_column($series, 'clicks')); + + self::assertSame(3, $sumOf($visits->clicksPerDayGlobal(30, null)), 'Administrator: alle Klicks.'); + + $anna = $this->policyForMember('anna', $this->ids['marketing'], Role::EDITOR); + self::assertSame(1, $sumOf($visits->clicksPerDayGlobal(30, $anna->visibleDomainIds()))); + + // Ohne Gruppe bleibt die Reihe vollstaendig, aber leer. + $_SESSION = []; + $niemand = $this->policyFor($this->login('niemand')); + $series = $visits->clicksPerDayGlobal(30, $niemand->visibleDomainIds()); + + self::assertCount(30, $series); + self::assertSame(0, $sumOf($series)); + } + + // --- Hilfen ---------------------------------------------------------- + + /** + * Legt ein Konto an und meldet es in der (CLI-tauglichen) Session an. + * + * @return int Benutzerkennung + */ + private function login(string $username, string $role = Role::USER): int + { + $id = $this->users->create($username, 'ein-langes-passwort', $role); + + $user = $this->users->find($id); + self::assertNotNull($user); + + $this->newAuth()->login($user); + + return $id; + } + + private function policyForMember(string $username, int $groupId, string $groupRole): AccessPolicy + { + $id = $this->login($username); + $this->groups->setMember($groupId, $id, $groupRole); + + return $this->policyFor($id); + } + + /** + * Frische Policy zum angemeldeten Benutzer. Bewusst jedes Mal neu: + * Auth und AccessPolicy halten je Request einen Zwischenspeicher. + */ + private function policyFor(int $userId): AccessPolicy + { + $auth = $this->authFor($userId); + + return new AccessPolicy($auth, $this->groups, new DomainRepository($this->db, $this->groups)); + } + + private function authFor(int $userId): Auth + { + $auth = $this->newAuth(); + self::assertSame($userId, $auth->id(), 'Es muss der erwartete Benutzer angemeldet sein.'); + + return $auth; + } + + private function newAuth(): Auth + { + return new Auth($this->config, new Session($this->config), $this->users); + } +} diff --git a/tests/MigrationUpgradeTest.php b/tests/MigrationUpgradeTest.php index f70c6cd..79f2b87 100644 --- a/tests/MigrationUpgradeTest.php +++ b/tests/MigrationUpgradeTest.php @@ -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); }