path = tempnam(sys_get_temp_dir(), 'kant-users-'); $url = 'sqlite:///'.str_replace('\\', '/', $this->path); $this->previous = [$_SERVER, $_ENV]; foreach (['APP_SECRET' => 'isolated-test-secret', 'DATABASE_URL' => $url, 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) { $_SERVER[$key] = $_ENV[$key] = $value; } $this->db = ConnectionFactory::create($url); TestSchema::build($this->db); $this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']); $this->db->insert('page', ['id' => 'p1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]); $pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media'))); $pages->publish('p1', $pages->saveDraft('p1', DemoContent::pages()['home'], 0), 1); $partials = new PartialRepository($this->db, new DocumentValidator()); foreach (DemoContent::areas() as $kind => $area) { $partials->create($kind, 'default', $area['name'], $area['document']); } foreach (['admin' => 'ROLE_ADMIN', 'zweite' => 'ROLE_ADMIN', 'editor' => 'ROLE_EDITOR'] as $username => $role) { $this->account($username, $role); } } protected function tearDown(): void { parent::tearDown(); $this->db->close(); gc_collect_cycles(); @unlink($this->path); foreach (glob($this->path.'-media/*') ?: [] as $file) { unlink($file); } if (is_dir($this->path.'-media')) { rmdir($this->path.'-media'); } [$_SERVER, $_ENV] = $this->previous; } public function testOnlyAnAdministratorReachesTheAccountsAtAll(): void { self::ensureKernelShutdown(); $anonymous = self::createClient(); $anonymous->followRedirects(false); $anonymous->request('GET', '/admin/benutzer'); self::assertSame('http://localhost/admin/login', $anonymous->getResponse()->headers->get('Location')); $editor = $this->login('editor'); $editor->request('GET', '/admin/benutzer'); self::assertSame(403, $editor->getResponse()->getStatusCode()); // An editor does have an account screen: changing the own password is not a privilege. $editor->request('GET', '/admin/konto'); self::assertSame(200, $editor->getResponse()->getStatusCode()); foreach ([ ['/admin/benutzer/neu', ['username' => 'geschmuggelt', 'password' => 'Ein-sehr-langes-Passwort', 'role' => 'ROLE_ADMIN']], ['/admin/benutzer/'.$this->id('editor').'/rolle', ['role' => 'ROLE_ADMIN']], ['/admin/benutzer/'.$this->id('admin').'/passwort', ['password' => 'Ein-sehr-langes-Passwort']], ['/admin/benutzer/'.$this->id('admin').'/loeschen', []], ] as [$url, $body]) { $editor->request('POST', $url, [...$body, '_csrf_token' => $this->token($editor, 'account')]); self::assertSame(403, $editor->getResponse()->getStatusCode(), $url); } self::assertSame(3, $this->accounts()); self::assertSame('ROLE_EDITOR', $this->roleOf('editor')); } public function testAnAccountIsCreatedAndCanSignIn(): void { $client = $this->login(); $this->submit($client, '/admin/benutzer/neu', ['username' => 'Redakteurin', 'password' => 'Ein-sehr-langes-Passwort', 'role' => 'ROLE_PUBLISHER']); self::assertSame('/admin/benutzer', $client->getResponse()->headers->get('Location')); // Stored in lower case, with the dates the list shows. $row = $this->db->fetchAssociative('SELECT * FROM cms_user WHERE username = ?', ['redakteurin']); self::assertNotFalse($row); self::assertSame('["ROLE_PUBLISHER"]', $row['roles']); self::assertNotNull($row['created_at']); self::assertNotNull($row['password_changed_at']); self::assertNull($row['last_login_at']); $fresh = $this->login('redakteurin', 'Ein-sehr-langes-Passwort'); $fresh->request('GET', '/admin/benutzer'); self::assertSame(403, $fresh->getResponse()->getStatusCode(), 'A publisher is not an administrator.'); // The login is recorded, so nobody has to guess whether an account is still in use. self::assertNotNull($this->db->fetchOne('SELECT last_login_at FROM cms_user WHERE username = ?', ['redakteurin'])); } /** @return iterable}> */ public static function refusedAccounts(): iterable { yield 'password too short' => [['username' => 'neu', 'password' => 'Dreizehn-Zeic', 'role' => 'ROLE_EDITOR']]; yield 'password beyond bcrypt' => [['username' => 'neu', 'password' => str_repeat('a', 73), 'role' => 'ROLE_EDITOR']]; yield 'password with a null byte' => [['username' => 'neu', 'password' => "Ein-langes-Pass\0wort", 'role' => 'ROLE_EDITOR']]; yield 'username too short' => [['username' => 'ab', 'password' => 'Ein-sehr-langes-Passwort', 'role' => 'ROLE_EDITOR']]; yield 'username with a space' => [['username' => 'neuer nutzer', 'password' => 'Ein-sehr-langes-Passwort', 'role' => 'ROLE_EDITOR']]; yield 'username starting with a dot' => [['username' => '.neu', 'password' => 'Ein-sehr-langes-Passwort', 'role' => 'ROLE_EDITOR']]; yield 'name already taken' => [['username' => 'editor', 'password' => 'Ein-sehr-langes-Passwort', 'role' => 'ROLE_EDITOR']]; yield 'unknown role' => [['username' => 'neu', 'password' => 'Ein-sehr-langes-Passwort', 'role' => 'ROLE_SUPERUSER']]; } #[DataProvider('refusedAccounts')] public function testRefusedAccountsAreNotCreated(array $body): void { $client = $this->login(); $this->submit($client, '/admin/benutzer/neu', $body); self::assertSame('/admin/benutzer', $client->getResponse()->headers->get('Location')); self::assertSame(3, $this->accounts()); self::assertSame('ROLE_EDITOR', $this->roleOf('editor'), 'An existing account must stay as it is.'); self::assertCount(1, $client->followRedirect()->filter('.notice.error')); } public function testEveryWriteNeedsACsrfToken(): void { $client = $this->login(); foreach ([ ['/admin/benutzer/neu', ['username' => 'ohnetoken', 'password' => 'Ein-sehr-langes-Passwort', 'role' => 'ROLE_ADMIN']], ['/admin/benutzer/'.$this->id('editor').'/rolle', ['role' => 'ROLE_ADMIN']], ['/admin/benutzer/'.$this->id('editor').'/passwort', ['password' => 'Ein-sehr-langes-Passwort']], ['/admin/benutzer/'.$this->id('editor').'/loeschen', []], ] as [$url, $body]) { $client->request('POST', $url, [...$body, '_csrf_token' => 'wrong']); self::assertSame('/admin/benutzer', $client->getResponse()->headers->get('Location'), $url); } self::assertSame(3, $this->accounts()); self::assertSame('ROLE_EDITOR', $this->roleOf('editor')); self::assertTrue($this->passwordWorks('editor', self::FIXTURE_PASSWORD)); $client->request('POST', '/admin/konto/passwort', ['current' => self::FIXTURE_PASSWORD, 'password' => 'Ein-sehr-langes-Passwort', '_csrf_token' => 'wrong']); self::assertSame('/admin/konto', $client->getResponse()->headers->get('Location')); self::assertTrue($this->passwordWorks('admin', self::FIXTURE_PASSWORD)); } public function testNobodyRemovesOrDemotesTheirOwnAccountSoAnAdministratorAlwaysRemains(): void { $client = $this->login(); $me = $this->id('admin'); $this->submit($client, '/admin/benutzer/'.$me.'/loeschen', []); self::assertSame(3, $this->accounts(), 'The own account must survive.'); $this->submit($client, '/admin/benutzer/'.$me.'/rolle', ['role' => 'ROLE_EDITOR']); self::assertSame('ROLE_ADMIN', $this->roleOf('admin'), 'The own role must stay.'); self::assertCount(1, $client->followRedirect()->filter('.notice.error')); // Another administrator can be demoted and removed, and the actor stays an administrator - // that is why at least one always remains. $this->submit($client, '/admin/benutzer/'.$this->id('zweite').'/rolle', ['role' => 'ROLE_EDITOR']); self::assertSame('ROLE_EDITOR', $this->roleOf('zweite')); $this->submit($client, '/admin/benutzer/'.$this->id('zweite').'/loeschen', []); self::assertSame(2, $this->accounts()); self::assertSame('ROLE_ADMIN', $this->roleOf('admin')); } public function testAResetPasswordSignsThatAccountOutEverywhere(): void { $editor = $this->login('editor'); $admin = $this->login(); $this->submit($admin, '/admin/benutzer/'.$this->id('editor').'/passwort', ['password' => 'Ein-ganz-neues-Passwort']); self::assertSame('/admin/benutzer', $admin->getResponse()->headers->get('Location')); // The editor's session held the old hash, so its next request is no longer signed in. $editor->request('GET', '/admin'); self::assertSame('http://localhost/admin/login', $editor->getResponse()->headers->get('Location')); self::assertFalse($this->passwordWorks('editor', self::FIXTURE_PASSWORD)); self::assertTrue($this->passwordWorks('editor', 'Ein-ganz-neues-Passwort')); // And the administrator who made the change stays signed in. $admin->request('GET', '/admin/benutzer'); self::assertSame(200, $admin->getResponse()->getStatusCode()); } public function testAChangedRoleSignsThatAccountOutEverywhere(): void { $editor = $this->login('editor'); $admin = $this->login(); $this->submit($admin, '/admin/benutzer/'.$this->id('editor').'/rolle', ['role' => 'ROLE_PUBLISHER']); self::assertSame('ROLE_PUBLISHER', $this->roleOf('editor')); $editor->request('GET', '/admin'); self::assertSame('http://localhost/admin/login', $editor->getResponse()->headers->get('Location')); } public function testChangingTheOwnPasswordNeedsTheCurrentOneAndKeepsThisSession(): void { $other = $this->login('editor'); $client = $this->login('editor'); foreach ([ 'wrong current password' => ['current' => 'Nicht-das-Passwort', 'password' => 'Ein-ganz-neues-Passwort'], 'the same password again' => ['current' => self::FIXTURE_PASSWORD, 'password' => self::FIXTURE_PASSWORD], 'new one too short' => ['current' => self::FIXTURE_PASSWORD, 'password' => 'Dreizehn-Zeic'], ] as $case => $body) { $this->submit($client, '/admin/konto/passwort', $body, 'account'); self::assertSame('/admin/konto', $client->getResponse()->headers->get('Location'), $case); self::assertTrue($this->passwordWorks('editor', self::FIXTURE_PASSWORD), $case); self::assertCount(1, $client->followRedirect()->filter('.notice.error'), $case); } $this->submit($client, '/admin/konto/passwort', ['current' => self::FIXTURE_PASSWORD, 'password' => 'Ein-ganz-neues-Passwort'], 'account'); self::assertSame('/admin/konto', $client->getResponse()->headers->get('Location')); self::assertFalse($this->passwordWorks('editor', self::FIXTURE_PASSWORD)); self::assertTrue($this->passwordWorks('editor', 'Ein-ganz-neues-Passwort')); // This session keeps working, because it was handed the fresh account. $client->request('GET', '/admin/konto'); self::assertSame(200, $client->getResponse()->getStatusCode()); // The other session of the same account is signed out. $other->request('GET', '/admin'); self::assertSame('http://localhost/admin/login', $other->getResponse()->headers->get('Location')); } public function testTheAccountScreenShowsTheOwnRoleButNoWayToChangeIt(): void { $client = $this->login('editor'); $crawler = $client->request('GET', '/admin/konto'); $head = $crawler->filter('.card-head')->first()->text(); self::assertStringContainsString('editor', $head); self::assertStringContainsString('Redakteur', $head); self::assertCount(0, $crawler->filter('select[name="role"]')); self::assertCount(0, $crawler->filter('a[href="/admin/benutzer"]'), 'An editor is not offered the user administration.'); } private function account(string $username, string $role): void { $this->db->insert('cms_user', [ 'id' => bin2hex(random_bytes(8)), 'username' => $username, 'password_hash' => password_hash(self::FIXTURE_PASSWORD, PASSWORD_BCRYPT, ['cost' => 4]), 'roles' => json_encode([$role], JSON_THROW_ON_ERROR), 'created_at' => gmdate('Y-m-d H:i:s'), 'password_changed_at' => gmdate('Y-m-d H:i:s'), ]); } private function login(string $username = 'admin', string $password = self::FIXTURE_PASSWORD): KernelBrowser { // Several sessions in one test: a new client needs the kernel down before it is created, // and every request boots its own kernel afterwards. self::ensureKernelShutdown(); $client = self::createClient(); $client->followRedirects(false); $crawler = $client->request('GET', '/admin/login'); $client->submit($crawler->selectButton('Anmelden')->form(['_username' => $username, '_password' => $password])); self::assertSame('http://localhost/admin', $client->getResponse()->headers->get('Location'), 'Anmeldung von '.$username); $client->followRedirect(); self::assertSame(200, $client->getResponse()->getStatusCode()); return $client; } private function submit(KernelBrowser $client, string $url, array $body, string $tokenId = 'users'): void { $client->request('POST', $url, [...$body, '_csrf_token' => $this->token($client, $tokenId)]); } /** * A token as the browser would carry it: read out of the form this client was served. The form * is addressed explicitly, because the first token on the page belongs to the logout form in * the shell and is issued for a different token id. */ private function token(KernelBrowser $client, string $id): string { [$url, $form] = 'users' === $id ? ['/admin/benutzer', 'form[action="/admin/benutzer/neu"]'] : ['/admin/konto', 'form[action="/admin/konto/passwort"]']; $crawler = $client->request('GET', $url); if (200 !== $client->getResponse()->getStatusCode()) { return 'no-token-for-this-account'; } return (string) $crawler->filter($form.' input[name="_csrf_token"]')->first()->attr('value'); } private function id(string $username): string { return (string) $this->db->fetchOne('SELECT id FROM cms_user WHERE username = ?', [$username]); } private function roleOf(string $username): string { return json_decode((string) $this->db->fetchOne('SELECT roles FROM cms_user WHERE username = ?', [$username]), true, 512, JSON_THROW_ON_ERROR)[0]; } private function accounts(): int { return (int) $this->db->fetchOne('SELECT COUNT(*) FROM cms_user'); } private function passwordWorks(string $username, string $password): bool { return password_verify($password, (string) $this->db->fetchOne('SELECT password_hash FROM cms_user WHERE username = ?', [$username])); } }