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