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