Eine Website ohne Beitraege redet nirgends von Beitraegen: Archiv und Feed antworten mit 404, der Feedverweis faellt aus dem Kopf, und der Hinweiskasten der Beitragsliste bleibt im Editor statt oeffentlich zu erscheinen. Das gilt auch je Kategorie, sobald deren letzter Beitrag weg ist. Dabei gefunden: die Liste fragte je Beitrag nach Bild und Begriffen - jetzt je eine Abfrage fuer die ganze Liste (22 -> 16 Abfragen auf der Startseite des Nachbaus). Unterstrichene Links gibt es nur noch im Fliesstext, dort mit einem Schalter im Designbereich; Karten, Menue, Kontaktzeilen und der Ansehen-Streifen haben keinen mehr. Die aktuelle Seite im Menue wird ueber das Schriftgewicht kenntlich - die Akzentfarbe fiel im dunklen Fussmenue durch die Kontrastpruefung. Neu: das Symbol der Website laesst sich festlegen. Aus einem Bild der Mediathek entstehen beim Speichern quadratische PNG-Dateien in 32 und 180 px; schlaegt das fehl, wird die Einstellung nicht gespeichert. Die Browserpruefung laeuft jetzt gegen jede Installation statt nur gegen die Demo: geprueft wird, was in der Sitemap steht, und das Ladebudget trennt Bilder vom Beiwerk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
332 lines
17 KiB
PHP
332 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Tests;
|
|
|
|
use App\Content\DemoContent;
|
|
use App\Content\DocumentValidator;
|
|
use App\Content\MediaLibrary;
|
|
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, 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<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]));
|
|
}
|
|
}
|