Konten gab es bisher nur über die Konsole. Unter "Benutzer" legt ein Administrator jetzt Konten an, ändert Rollen, setzt Passwörter und löscht Konten; unter "Mein Konto" ändert jeder sein eigenes Passwort, aber nur mit dem aktuellen in der Hand. Eine Rolle je Konto: Administrator, Veröffentlicher, Redakteur. Ein Passwort oder eine Rolle zu ändern meldet jede offene Sitzung dieses Kontos ab - dafür war nichts zu bauen, CmsUser::isEqualTo vergleicht Hash und Rollen mit der Datenbank. Genau eine Sitzung überlebt: die, die ihr eigenes Passwort geändert hat. "Jemanden sofort aussperren" ist damit dasselbe wie "Passwort setzen". Es bleibt immer ein Administrator übrig, nicht über eine Zählung, sondern weil niemand sein eigenes Konto löschen oder herabstufen kann und jede Änderung die Administratorrolle voraussetzt. Die zusätzliche Prüfung auf einen zweiten Administrator war damit unerreichbar und ist wieder heraus. Mediathek (Umut: "das sollte direkt laden in pagination"): /admin/media zeigt die Bilder jetzt selbst, 24 je Seite, neueste zuerst, mit Blättern und einem Schalter für archivierte. Hochladen und Bildangaben laufen über gewöhnliche Formulare ohne JavaScript; der Dialog bleibt der Weg, beim Bearbeiten ein Bild auszuwählen. Die Bild-ID steht in jeder Karte, weil Beitragsbild und Teilen-Vorschau genau diese ID erwarten. Die Migrationsliste der Testfixtures stand dreimal da und steht jetzt einmal in tests/TestSchema.php. 217 PHP-Tests / 845 Assertions unter PHP 8.2.12 und 8.4.22. axe deckt jetzt elf Bildschirme ab statt drei - alle Verwaltungsseiten, nicht nur Anmeldung, Seitenliste und Editor -, null Verstöße. Nicht geprüft: kein Screenreader; Blättern und Upload sind mit zwei Bildern geprüft, nicht mit einer großen Bibliothek. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
331 lines
17 KiB
PHP
331 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Tests;
|
|
|
|
use App\Content\DemoContent;
|
|
use App\Content\DocumentValidator;
|
|
use App\Content\PageRepository;
|
|
use App\Content\PartialRepository;
|
|
use App\Content\SiteSettings;
|
|
use App\Infrastructure\ConnectionFactory;
|
|
use App\Kernel;
|
|
use Doctrine\DBAL\Connection;
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
|
|
|
/**
|
|
* The user administration, and above all the two properties that matter about it: only an
|
|
* administrator can touch an account, and a changed password or role ends every open session of
|
|
* that account.
|
|
*
|
|
* Several tests hold two sessions at once, which is the only way to show the second property. The
|
|
* response assertions are therefore written against a named client instead of the framework's
|
|
* "current" one - with two clients around, "current" is whichever was created last.
|
|
*/
|
|
final class UserAdminTest extends WebTestCase
|
|
{
|
|
private const FIXTURE_PASSWORD = 'Fixture-only-password';
|
|
|
|
private Connection $db;
|
|
private string $path;
|
|
private array $previous;
|
|
|
|
protected static function getKernelClass(): string
|
|
{
|
|
return Kernel::class;
|
|
}
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->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));
|
|
$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<string, array{array<string, string>}> */
|
|
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]));
|
|
}
|
|
}
|