Files
urlshort-qr-gen/tests/AccessControlTest.php
T
florian.wildeandClaude Opus 5 85b9576b38 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>
2026-08-14 12:03:41 +02:00

351 lines
13 KiB
PHP

<?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);
}
}