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:
+15
-6
@@ -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 ------------------------------------
|
||||
|
||||
@@ -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/<datei>` 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!)
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Config;
|
||||
use App\Database;
|
||||
use App\Migrator;
|
||||
use App\Repository\DomainRepository;
|
||||
use App\Repository\GroupRepository;
|
||||
use App\Repository\LinkRepository;
|
||||
use App\Repository\QrDesignRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Repository\VisitRepository;
|
||||
use App\Security\AccessPolicy;
|
||||
use App\Security\Auth;
|
||||
use App\Security\Role;
|
||||
use App\Security\Session;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Die eigentliche Zusage der Mehrbenutzerfaehigkeit: was einer Gruppe
|
||||
* gehoert, bleibt in dieser Gruppe.
|
||||
*
|
||||
* Geprueft wird ueber die echte Kette Auth -> 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<string,int> */
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
foreach ([$this->partialDir, $this->domainsDir] as $dir) {
|
||||
foreach (glob($dir . '/*') ?: [] as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
if (is_dir($this->partialDir)) {
|
||||
rmdir($this->partialDir);
|
||||
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