Harden KantSites after the security review
- The installer only opens with a per-installation key that install.php creates and shows; the key is deleted with the first account. - Lock files keep .env, the database, backups and logs out of reach when a domain points at the project folder instead of public/. - Linked sections render by their own schema version, so an old section cannot carry unfiltered HTML into a newer page. - Editors no longer move, trash or change areas and post data of published pages; a scheduled publication goes live with the revision that was scheduled (migration 20261012000100). - Form stamps are bound to their form, IPv6 senders count per /64, and a flood per form or overall stops storing with a visible message. - The consent redirect rejects control characters, update targets must look like 1.2.3, and own CSS cannot reach outside its block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
14e7a3b310
commit
ad7c333215
43 files changed
+810
-53
No files matched your search
@@ -272,7 +272,9 @@ final class ArchiveUpdateTest extends TestCase
|
||||
// Beiwerk, das eine laufende Installation nicht liest.
|
||||
// plugins/ ebenso Datei für Datei: mitgelieferte Plugins werden aktualisiert, ein vom
|
||||
// Betreiber installiertes bleibt stehen (ADR 102).
|
||||
$anders = ['public', 'plugins', 'README.md', 'LICENSE', '.env.example'];
|
||||
// Die .htaccess im Projektstamm ersetzt das Update nicht: sie kann dem Betreiber gehören.
|
||||
// Fehlt sie, legt die Website sie selbst an (App\Ops\Protection, ADR 116).
|
||||
$anders = ['public', 'plugins', 'README.md', 'LICENSE', '.env.example', '.htaccess'];
|
||||
foreach ($liste($mit[1]) as $eintrag) {
|
||||
self::assertContains(
|
||||
$eintrag,
|
||||
|
||||
@@ -158,7 +158,7 @@ final class BacklogTest extends TestCase
|
||||
|
||||
// Abgeschickt ohne E-Mail: die Seite kommt mit Meldung zurück, am Formular im Fuß.
|
||||
$issued = time() - 10;
|
||||
$antwort = $this->request('/formular/kontakt', 'POST', ['page' => 'home', 'block' => 'fuss-formular', 'issued' => (string) $issued, 'issuedSignature' => hash_hmac('sha256', (string) $issued, 'test-only'), 'email' => '', 'datenschutz' => 'ja']);
|
||||
$antwort = $this->request('/formular/kontakt', 'POST', ['page' => 'home', 'block' => 'fuss-formular', 'issued' => (string) $issued, 'issuedSignature' => FormRepository::stamp('kontakt', $issued, 'test-only'), 'email' => '', 'datenschutz' => 'ja']);
|
||||
self::assertSame(422, $antwort->getStatusCode());
|
||||
self::assertStringContainsString('Dieses Feld wird benötigt.', $antwort->getContent());
|
||||
}
|
||||
|
||||
@@ -3098,6 +3098,26 @@ final class CmsTest extends TestCase
|
||||
self::assertStringNotContainsString('Viele Möglichkeiten', $this->request('/')->getContent());
|
||||
}
|
||||
|
||||
public function testALinkedSectionIsRenderedByItsOwnSchemaVersion(): void
|
||||
{
|
||||
// Eine Sektion mit altem Schema-Stand: ihr Text lief nicht durch den Filter für
|
||||
// formatierten Text. In einer neueren Seite darf er deshalb nicht roh erscheinen (ADR 116).
|
||||
$this->partials->create('section', 'alt', 'Alte Sektion', [
|
||||
'schemaVersion' => 11, 'title' => 'Alt', 'description' => 'Alte Sektion.',
|
||||
'blocks' => [['id' => 'alt-section', 'type' => 'section', 'children' => [
|
||||
['id' => 'alt-text', 'type' => 'text', 'props' => ['text' => '<meta http-equiv="refresh" content="0;url=https://fremd.example"><a href="https://fremd.example">Hier</a>']],
|
||||
]]],
|
||||
]);
|
||||
$document = DemoContent::starter('Neu');
|
||||
$document['schemaVersion'] = DocumentValidator::SCHEMA_VERSION;
|
||||
$document['blocks'][] = ['id' => 'ref', 'type' => 'partialref', 'props' => ['partial' => 'alt']];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
|
||||
$html = (string) $this->request('/')->getContent();
|
||||
self::assertStringNotContainsString('<meta http-equiv="refresh"', $html);
|
||||
self::assertStringContainsString('<meta http-equiv=', $html, 'Der Text erscheint als Text.');
|
||||
}
|
||||
|
||||
public function testALinkedSectionChangesOnEveryPageThatEmbedsIt(): void
|
||||
{
|
||||
$sectionId = $this->partials->create('section', 'aufruf', 'Aufruf-Band', [
|
||||
@@ -3470,6 +3490,24 @@ display: flex', '#b1{display:flex;color:red}'];
|
||||
$this->pages->saveDraft('page-1', $document, 0);
|
||||
}
|
||||
|
||||
public function testOwnCssStaysInsideItsBlock(): void
|
||||
{
|
||||
$code = new CustomCode();
|
||||
// Geschwister des Blocks selbst liegen außerhalb - die ganze Seite wäre umgestaltbar (ADR 116).
|
||||
foreach (['& ~ *{display:none}', '&+p{color:red}', '&[a="x]{color:red}', 'color:red /*', 'width:10px*/'] as $css) {
|
||||
try {
|
||||
$code->checkCss($css);
|
||||
self::fail('Angenommen: '.$css);
|
||||
} catch (InvalidDocument) {
|
||||
self::addToAssertionCount(1);
|
||||
}
|
||||
// Was schon gespeichert ist und heute nicht mehr gilt, fällt still weg statt die Seite zu brechen.
|
||||
self::assertSame('', $code->compileCss('b1', $css));
|
||||
}
|
||||
// Innerhalb des Blocks bleiben ~ und + erlaubt.
|
||||
self::assertSame('#b1 .a~.b{color:red}#b1 h3+p{margin:0}', $code->compileCss('b1', '& .a~.b{color:red}& h3+p{margin:0}'));
|
||||
}
|
||||
|
||||
public function testScriptChangesAreComparedAgainstWhatIsStored(): void
|
||||
{
|
||||
$code = new CustomCode();
|
||||
@@ -4614,6 +4652,16 @@ Achern']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testTheConsentFormRedirectsOnlyWithinThisSite(): void
|
||||
{
|
||||
$this->askConsent();
|
||||
// Browser entfernen Tabulator und Zeilenumbruch: aus /<Tab>/fremd würde //fremd (ADR 116).
|
||||
foreach (["/\t/fremd.example", "/\n/fremd.example", '//fremd.example', '/\fremd.example', 'https://fremd.example', '/ /fremd.example'] as $back) {
|
||||
self::assertSame('/', $this->request('/einwilligung', 'POST', ['choice' => 'none', 'back' => $back])->headers->get('Location'), json_encode($back));
|
||||
}
|
||||
self::assertSame('/kontakt?a=1#x', $this->request('/einwilligung', 'POST', ['choice' => 'none', 'back' => '/kontakt?a=1#x'])->headers->get('Location'));
|
||||
}
|
||||
|
||||
public function testTheConsentScriptSavesWithoutARedirect(): void
|
||||
{
|
||||
$this->askConsent();
|
||||
|
||||
@@ -356,6 +356,54 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
||||
}
|
||||
|
||||
public function testEditorsChangeLivePagesOnlyThroughAPublisher(): void
|
||||
{
|
||||
// Eine veröffentlichte Unterseite und ein reiner Entwurf (ADR 116).
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||||
$live = $pages->createPage('leistungen', null, 'page', DemoContent::starter('Leistungen'));
|
||||
$seite = $pages->findForEditor($live);
|
||||
$pages->publish($live, $seite['draft_revision_id'], $seite['version']);
|
||||
$entwurf = $pages->createPage('entwurf', null, 'page', DemoContent::starter('Entwurf'));
|
||||
$version = static fn (string $id): int => (int) $pages->findForEditor($id)['version'];
|
||||
|
||||
$client = $this->login('editor');
|
||||
$token = $client->request('GET', '/admin')->filter('input[name="_csrf_token"]')->last()->attr('value');
|
||||
foreach (['move' => ['slug' => 'umbenannt'], 'trash' => [], 'bereiche' => ['headerArea' => '', 'footerArea' => 'none'], 'meta' => ['robots' => 'noindex']] as $aktion => $felder) {
|
||||
$client->request('POST', '/admin/pages/'.$live.'/'.$aktion, ['_csrf_token' => $token, 'expectedVersion' => $version($live), ...$felder]);
|
||||
self::assertResponseRedirects();
|
||||
}
|
||||
$nachher = $this->db->fetchAssociative('SELECT path, deleted_at, footer_area, robots FROM page WHERE id = ?', [$live]);
|
||||
self::assertSame('leistungen', $nachher['path'], 'Ein Redakteur verschiebt keine veröffentlichte Seite.');
|
||||
self::assertNull($nachher['deleted_at'], 'Ein Redakteur legt keine veröffentlichte Seite in den Papierkorb.');
|
||||
self::assertNull($nachher['footer_area']);
|
||||
self::assertNotSame('noindex', $nachher['robots']);
|
||||
|
||||
// Einen Entwurf, der nie online war, bearbeitet er weiter frei.
|
||||
$client->request('POST', '/admin/pages/'.$entwurf.'/move', ['_csrf_token' => $token, 'expectedVersion' => $version($entwurf), 'slug' => 'neuer-name']);
|
||||
self::assertSame('neuer-name', $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$entwurf]));
|
||||
|
||||
// Wer veröffentlichen darf, darf auch das.
|
||||
$client = $this->login('publisher', $client);
|
||||
$token = $client->request('GET', '/admin')->filter('input[name="_csrf_token"]')->last()->attr('value');
|
||||
$client->request('POST', '/admin/pages/'.$live.'/move', ['_csrf_token' => $token, 'expectedVersion' => $version($live), 'slug' => 'umbenannt']);
|
||||
self::assertSame('umbenannt', $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$live]));
|
||||
}
|
||||
|
||||
public function testAScheduledPublicationPublishesTheRevisionThatWasScheduled(): void
|
||||
{
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||||
$id = $pages->createPage('termin', null, 'page', DemoContent::starter('Geprüfter Stand'));
|
||||
$geprueft = $pages->findForEditor($id)['draft_revision_id'];
|
||||
$pages->schedule($id, '2026-01-01 08:00', (int) $pages->findForEditor($id)['version']);
|
||||
// Nach dem Planen speichert jemand einen anderen Entwurf.
|
||||
$pages->saveDraft($id, DemoContent::starter('Später gespeichert'), (int) $pages->findForEditor($id)['version']);
|
||||
self::assertNotSame($geprueft, $pages->findForEditor($id)['draft_revision_id']);
|
||||
|
||||
self::assertSame(['termin'], $pages->publishDue());
|
||||
self::assertSame($geprueft, $this->db->fetchOne('SELECT published_revision_id FROM page WHERE id = ?', [$id]), 'Online geht der geplante Stand, nicht der spätere Entwurf.');
|
||||
self::assertNull($this->db->fetchOne('SELECT publish_revision_id FROM page WHERE id = ?', [$id]));
|
||||
}
|
||||
|
||||
public function testAdministratorsDeleteAPageWithAllRevisionsOnlyWithACsrfToken(): void
|
||||
{
|
||||
$client = $this->login('admin');
|
||||
@@ -499,7 +547,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
{
|
||||
return [
|
||||
'page' => 'home', 'block' => 'formblock', 'website' => '',
|
||||
'issued' => $issued, 'issuedSignature' => hash_hmac('sha256', $issued, 'isolated-test-secret'),
|
||||
'issued' => $issued, 'issuedSignature' => FormRepository::stamp('kontakt', (int) $issued, 'isolated-test-secret'),
|
||||
'name' => 'Umut', 'email' => 'umut@example.com', 'thema' => 'Beratung',
|
||||
'nachricht' => 'Bitte um Rückruf.', 'datenschutz' => 'ja',
|
||||
];
|
||||
@@ -611,7 +659,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
*/
|
||||
public static function droppedSubmissions(): iterable
|
||||
{
|
||||
$sign = static fn (string $issued): string => hash_hmac('sha256', $issued, 'isolated-test-secret');
|
||||
$sign = static fn (string $issued): string => FormRepository::stamp('kontakt', (int) $issued, 'isolated-test-secret');
|
||||
yield 'honeypot filled' => [static fn (array $body): array => [...$body, 'website' => 'https://spam.example']];
|
||||
yield 'submitted too fast' => [static fn (array $body): array => [...$body, 'issued' => (string) time(), 'issuedSignature' => $sign((string) time())]];
|
||||
yield 'timestamp forged' => [static fn (array $body): array => [...$body, 'issuedSignature' => 'nicht-signiert']];
|
||||
@@ -650,7 +698,47 @@ final class EditorSecurityTest extends WebTestCase
|
||||
$client->request('POST', '/formular/kontakt', $this->validBody((string) (time() - 10)));
|
||||
self::assertResponseStatusCodeSame(303);
|
||||
self::assertSame(FormRepository::RATE_LIMIT, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
|
||||
self::assertTrue($forms->rateLimited(hash_hmac('sha256', '127.0.0.1', 'isolated-test-secret')));
|
||||
self::assertTrue($forms->rateLimited(FormRepository::senderKey('127.0.0.1', 'isolated-test-secret')));
|
||||
}
|
||||
|
||||
public function testSendersOfOneIpv6NetworkCountTogether(): void
|
||||
{
|
||||
// Ein Anschluss hat ein ganzes /64-Netz; jede Adresse daraus ist derselbe Absender (ADR 116).
|
||||
$a = FormRepository::senderKey('2001:db8:1:2:aaaa::1', 's');
|
||||
self::assertSame($a, FormRepository::senderKey('2001:db8:1:2:ffff:1:2:3', 's'));
|
||||
self::assertNotSame($a, FormRepository::senderKey('2001:db8:1:3::1', 's'));
|
||||
self::assertNotSame(FormRepository::senderKey('192.0.2.1', 's'), FormRepository::senderKey('192.0.2.2', 's'));
|
||||
}
|
||||
|
||||
public function testAStampFromOneFormDoesNotOpenAnother(): void
|
||||
{
|
||||
// Das Siegel gilt für ein Formular; eines von einer anderen Seite lässt sich nicht
|
||||
// wiederverwenden (ADR 116). Der Versand wird still verworfen wie bei jedem Bot.
|
||||
$this->contactForm();
|
||||
$this->pageWithForm();
|
||||
$client = self::createClient();
|
||||
$issued = (string) (time() - 10);
|
||||
$client->request('POST', '/formular/kontakt', [...$this->validBody($issued), 'issuedSignature' => FormRepository::stamp('anderes', (int) $issued, 'isolated-test-secret')]);
|
||||
self::assertResponseStatusCodeSame(303);
|
||||
self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
|
||||
}
|
||||
|
||||
public function testAFloodStopsStoringAndTellsTheVisitor(): void
|
||||
{
|
||||
$forms = $this->contactForm();
|
||||
$this->pageWithForm();
|
||||
$form = $forms->findBySlug('kontakt');
|
||||
// Viele verschiedene Absender in der letzten Stunde: die Grenze je Formular greift.
|
||||
for ($i = 0; $i < FormRepository::FORM_RATE_LIMIT; ++$i) {
|
||||
$forms->store($form['id'], ['name' => 'x'], true, 'absender-'.$i);
|
||||
}
|
||||
self::assertTrue($forms->flooded($form['id']));
|
||||
$client = self::createClient();
|
||||
$client->request('POST', '/formular/kontakt', $this->validBody((string) (time() - 10)));
|
||||
// Sichtbar statt still: ein echter Besucher erfährt, dass er anrufen sollte.
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertStringContainsString('Gerade gehen sehr viele Anfragen ein', (string) $client->getResponse()->getContent());
|
||||
self::assertSame(FormRepository::FORM_RATE_LIMIT, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'));
|
||||
}
|
||||
|
||||
public function testAFormCarriesItsOwnColoursOntoThePageAndTheAdministrationRefusesBadOnes(): void
|
||||
|
||||
@@ -291,7 +291,7 @@ final class FormStepsTest extends TestCase
|
||||
|
||||
$issued = time() - 10;
|
||||
$grund = [
|
||||
'page' => 'home', 'block' => 'preis', 'issued' => (string) $issued, 'issuedSignature' => hash_hmac('sha256', (string) $issued, 'test-only'),
|
||||
'page' => 'home', 'block' => 'preis', 'issued' => (string) $issued, 'issuedSignature' => FormRepository::stamp('preisfinder', $issued, 'test-only'),
|
||||
'immobilienart' => 'Grundstück', 'wohnflaeche' => '99', 'ausstattung' => ['Balkon'], 'flaeche' => '640', 'email' => 'x@example.org', 'datenschutz' => 'ja',
|
||||
];
|
||||
$antwort = $this->request('/formular/preisfinder', 'POST', $grund);
|
||||
|
||||
@@ -23,6 +23,8 @@ final class InstallFlowTest extends TestCase
|
||||
{
|
||||
private string $root;
|
||||
private string $database;
|
||||
/** @var array<string, string> Cookies wie im Browser: der Nachweis des Installationsschlüssels. */
|
||||
private array $cookies = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
@@ -58,6 +60,11 @@ final class InstallFlowTest extends TestCase
|
||||
self::assertSame(302, $response->getStatusCode());
|
||||
self::assertSame('/install', $response->headers->get('Location'));
|
||||
|
||||
// 0b. Ohne Installationsschlüssel öffnet sich kein Schritt (ADR 116).
|
||||
self::assertSame('/install/schluessel', $this->request('/install')->headers->get('Location'));
|
||||
self::assertSame('/install/schluessel', $this->request('/install/datenbank')->headers->get('Location'));
|
||||
$this->unlock();
|
||||
|
||||
// 1. The system check.
|
||||
$check = $this->request('/install');
|
||||
self::assertSame(200, $check->getStatusCode());
|
||||
@@ -92,6 +99,9 @@ final class InstallFlowTest extends TestCase
|
||||
self::assertStringContainsString('Fertig.', $done->getContent());
|
||||
self::assertStringContainsString('Impressum und Datenschutzerklärung ausfüllen', $done->getContent());
|
||||
|
||||
// Der Schlüssel hat seine Arbeit getan und liegt nicht mehr herum.
|
||||
self::assertFileDoesNotExist($this->root.'/var/install.key');
|
||||
|
||||
// 6. The installer is over, and the website answers.
|
||||
self::assertSame(404, $this->request('/install')->getStatusCode());
|
||||
self::assertSame(404, $this->request('/install/konto')->getStatusCode());
|
||||
@@ -105,8 +115,41 @@ final class InstallFlowTest extends TestCase
|
||||
self::assertSame(200, $this->request('/admin/login')->getStatusCode());
|
||||
}
|
||||
|
||||
public function testTheInstallerOpensOnlyWithItsKey(): void
|
||||
{
|
||||
// Wer die frische Installation zuerst aufruft, bekommt nur die Frage nach dem Schlüssel.
|
||||
$page = $this->request('/install/schluessel');
|
||||
self::assertSame(200, $page->getStatusCode());
|
||||
self::assertStringContainsString('Installationsschlüssel', (string) $page->getContent());
|
||||
$key = trim((string) file_get_contents($this->root.'/var/install.key'));
|
||||
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $key);
|
||||
|
||||
// Ein falscher Schlüssel öffnet nichts und setzt kein Cookie.
|
||||
$wrong = $this->request('/install/schluessel', 'POST', ['schluessel' => str_repeat('0', 32)]);
|
||||
self::assertSame(200, $wrong->getStatusCode());
|
||||
self::assertStringContainsString('passt nicht', (string) $wrong->getContent());
|
||||
self::assertSame([], $wrong->headers->getCookies());
|
||||
// Auch ein selbst gebautes Cookie nicht.
|
||||
$this->cookies = ['kant_install' => hash_hmac('sha256', 'installer', 'geraten')];
|
||||
self::assertSame('/install/schluessel', $this->request('/install')->headers->get('Location'));
|
||||
|
||||
// Der Verweis von install.php trägt den Schlüssel in der Adresse; danach geht es ohne ihn weiter.
|
||||
$this->cookies = [];
|
||||
$open = $this->request('/install/schluessel?schluessel='.$key);
|
||||
self::assertSame('/install', $open->headers->get('Location'));
|
||||
$cookie = $open->headers->getCookies()[0];
|
||||
self::assertSame('kant_install', $cookie->getName());
|
||||
self::assertTrue($cookie->isHttpOnly());
|
||||
self::assertSame('strict', $cookie->getSameSite());
|
||||
self::assertSame('/install', $cookie->getPath());
|
||||
self::assertNotSame($key, $cookie->getValue(), 'Im Cookie steht ein Nachweis, nicht der Schlüssel.');
|
||||
$this->cookies = ['kant_install' => (string) $cookie->getValue()];
|
||||
self::assertSame(200, $this->request('/install')->getStatusCode());
|
||||
}
|
||||
|
||||
public function testAStepWithoutItsRequirementSendsYouBack(): void
|
||||
{
|
||||
$this->unlock();
|
||||
// Somebody who guesses the address of a later step does not get past the missing database.
|
||||
self::assertSame('/install/datenbank', $this->request('/install/migration')->headers->get('Location'));
|
||||
self::assertSame('/install/migration', $this->request('/install/website')->headers->get('Location'));
|
||||
@@ -118,6 +161,15 @@ final class InstallFlowTest extends TestCase
|
||||
self::assertFileDoesNotExist($this->root.'/.env');
|
||||
}
|
||||
|
||||
/** Gibt den Installationsschlüssel ein, wie es der Betreiber tut, und behält das Cookie. */
|
||||
private function unlock(): void
|
||||
{
|
||||
$this->request('/install/schluessel');
|
||||
$response = $this->request('/install/schluessel', 'POST', ['schluessel' => trim((string) file_get_contents($this->root.'/var/install.key'))]);
|
||||
self::assertSame('/install', $response->headers->get('Location'));
|
||||
$this->cookies['kant_install'] = (string) $response->headers->getCookies()[0]->getValue();
|
||||
}
|
||||
|
||||
/** Posts a form with the signature the rendered page carries, the way a browser would. */
|
||||
private function submit(string $url, Response $page, array $fields): Response
|
||||
{
|
||||
@@ -143,7 +195,7 @@ final class InstallFlowTest extends TestCase
|
||||
}
|
||||
$kernel = new InstallTestKernel($this->root);
|
||||
try {
|
||||
$request = Request::create($url, $method, $body);
|
||||
$request = Request::create($url, $method, $body, $this->cookies);
|
||||
$response = $kernel->handle($request);
|
||||
$kernel->terminate($request, $response);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Domain\CmsUser;
|
||||
use App\Domain\InvalidPage;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Ops\Installation;
|
||||
use App\Ops\Protection;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -48,6 +49,38 @@ final class InstallationTest extends TestCase
|
||||
$this->installation = $this->make();
|
||||
}
|
||||
|
||||
public function testMissingLockFilesAreAddedButAnOwnRootFileStays(): void
|
||||
{
|
||||
// var/ gibt es nicht im Archiv, also legt die Website die Sperrdatei selbst an (ADR 116).
|
||||
mkdir($this->root.'/config/schutz', 0o775, true);
|
||||
mkdir($this->root.'/vendor', 0o775, true);
|
||||
foreach (['sperren', 'stamm'] as $name) {
|
||||
copy(\dirname(__DIR__).'/config/schutz/'.$name.'.htaccess', $this->root.'/config/schutz/'.$name.'.htaccess');
|
||||
}
|
||||
file_put_contents($this->root.'/.htaccess', '# eigene Regeln
|
||||
');
|
||||
Protection::reset();
|
||||
Protection::ensure($this->root);
|
||||
self::assertStringContainsString('Require all denied', (string) file_get_contents($this->root.'/var/.htaccess'));
|
||||
self::assertStringContainsString('Require all denied', (string) file_get_contents($this->root.'/vendor/.htaccess'));
|
||||
self::assertSame('# eigene Regeln
|
||||
', file_get_contents($this->root.'/.htaccess'), 'Eine eigene Datei im Projektstamm bleibt.');
|
||||
Protection::reset();
|
||||
}
|
||||
|
||||
public function testTheInstallKeyIsCreatedOnceAndCheckedStrictly(): void
|
||||
{
|
||||
$key = $this->installation->installKey();
|
||||
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $key);
|
||||
self::assertSame($key, $this->installation->installKey(), 'Einmal angelegt, bleibt er.');
|
||||
self::assertTrue($this->installation->acceptsKey(' '.strtoupper($key).' '), 'Abgetippt mit Leerzeichen und Großbuchstaben zählt.');
|
||||
self::assertFalse($this->installation->acceptsKey(substr($key, 0, 31)));
|
||||
self::assertFalse($this->installation->acceptsKey(''));
|
||||
self::assertTrue($this->installation->acceptsProof($this->installation->keyProof()));
|
||||
self::assertFalse($this->installation->acceptsProof(''));
|
||||
self::assertFalse($this->installation->acceptsProof($key));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->db->close();
|
||||
|
||||
@@ -85,6 +85,25 @@ final class SingleFileInstallerTest extends TestCase
|
||||
self::assertFileDoesNotExist(\dirname($this->target).'/entwischt.txt');
|
||||
}
|
||||
|
||||
public function testTheInstallerKeyIsWrittenForTheWebInstaller(): void
|
||||
{
|
||||
// install.php legt den Schlüssel dort an, wo der Installer ihn sucht (ADR 116).
|
||||
$key = kant_install_key($this->target);
|
||||
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $key);
|
||||
self::assertSame($key, trim((string) file_get_contents($this->target.'/var/install.key')));
|
||||
self::assertSame($key, kant_install_key($this->target), 'Ein zweiter Aufruf liest denselben Schlüssel.');
|
||||
}
|
||||
|
||||
public function testTheReleaseCarriesItsLockFiles(): void
|
||||
{
|
||||
// Die Sperrdateien liegen im Repository und gleichen ihrer Vorlage (ADR 116).
|
||||
$root = \dirname(__DIR__);
|
||||
self::assertFileEquals($root.'/config/schutz/stamm.htaccess', $root.'/.htaccess');
|
||||
foreach (['bin', 'config', 'migrations', 'plugins', 'src', 'templates'] as $ordner) {
|
||||
self::assertFileEquals($root.'/config/schutz/sperren.htaccess', $root.'/'.$ordner.'/.htaccess', $ordner);
|
||||
}
|
||||
}
|
||||
|
||||
public function testThePreflightNamesWhatIsMissing(): void
|
||||
{
|
||||
$checks = kant_checks($this->target);
|
||||
|
||||
@@ -23,6 +23,7 @@ use DoctrineMigrations\Version20260915000100;
|
||||
use DoctrineMigrations\Version20260916000100;
|
||||
use DoctrineMigrations\Version20260917000100;
|
||||
use DoctrineMigrations\Version20261009000100;
|
||||
use DoctrineMigrations\Version20261012000100;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
@@ -57,6 +58,7 @@ final class TestSchema
|
||||
Version20260916000100::class,
|
||||
Version20260917000100::class,
|
||||
Version20261009000100::class,
|
||||
Version20261012000100::class,
|
||||
] as $migration) {
|
||||
(new $migration($db, new NullLogger()))->up($schema);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Tests;
|
||||
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\FormRepository;
|
||||
use App\Content\Languages;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\PageRepository;
|
||||
@@ -312,7 +313,7 @@ final class TranslationTest extends TestCase
|
||||
public function testFormsSpeakTheLanguageOfThePageButSendInTheMainLanguage(): void
|
||||
{
|
||||
$this->languages->save(['en']);
|
||||
$forms = new \App\Content\FormRepository($this->db);
|
||||
$forms = new FormRepository($this->db);
|
||||
$translations = ['en' => ['buttonLabel' => 'Send request', 'success' => 'Thanks!', 'fields' => [
|
||||
'art' => ['label' => 'Type of enquiry', 'options' => ['Beratung' => 'Advice', 'Angebot' => 'Quote', 'Gibt es nicht' => 'x']],
|
||||
'email' => ['label' => 'Email', 'help' => ''],
|
||||
@@ -352,7 +353,7 @@ final class TranslationTest extends TestCase
|
||||
self::assertStringNotContainsString('name="language"', $german);
|
||||
|
||||
$issued = time() - 10;
|
||||
$body = ['page' => 'kontakt', 'block' => 'f1', 'language' => 'en', 'issued' => (string) $issued, 'issuedSignature' => hash_hmac('sha256', (string) $issued, 'test-only'), 'art' => 'Beratung', 'email' => ''];
|
||||
$body = ['page' => 'kontakt', 'block' => 'f1', 'language' => 'en', 'issued' => (string) $issued, 'issuedSignature' => FormRepository::stamp('anfrage', $issued, 'test-only'), 'art' => 'Beratung', 'email' => ''];
|
||||
$refused = $this->request('/formular/anfrage', 'POST', $body);
|
||||
self::assertSame(422, $refused->getStatusCode());
|
||||
self::assertStringContainsString('This field is required.', (string) $refused->getContent());
|
||||
|
||||
Reference in new issue
Block a user