path = tempnam(sys_get_temp_dir(), 'kant-tasks-web-'); $url = 'sqlite:///'.str_replace('\\', '/', $this->path); $this->previous = [$_SERVER, $_ENV]; foreach (['APP_SECRET' => self::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' => '{}']); foreach (['admin' => 'ROLE_ADMIN', 'editor' => 'ROLE_EDITOR'] as $username => $role) { $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'), ]); } } protected function tearDown(): void { parent::tearDown(); $this->db->close(); gc_collect_cycles(); @unlink($this->path); [$_SERVER, $_ENV] = $this->previous; } public function testTheScreenNamesEveryTaskAndTheWayWithoutVisitors(): void { $client = $this->login(); $client->request('GET', '/admin/aufgaben'); self::assertSame(200, $client->getResponse()->getStatusCode()); $html = (string) $client->getResponse()->getContent(); foreach (['publish-due', 'stats-prune', 'backup', 'update-check', 'cleanup', 'zentrale'] as $task) { self::assertStringContainsString($task, $html); } // Die Einschränkung gehört auf den Bildschirm und nicht in die Dokumentation: wer sie // nicht kennt, sucht den Fehler später an der falschen Stelle. self::assertStringContainsString('Ohne Besuch und ohne geöffnete Verwaltung passiert nichts.', $html); self::assertStringContainsString('/cms-cron/'.Scheduler::cronToken(self::SECRET), $html); // Und der Plan steht danach in der Datenbank - eine Zeile je Aufgabe, nicht mehr. self::assertSame(6, (int) $this->db->fetchOne('SELECT COUNT(*) FROM scheduled_task')); } public function testStartingWorkNeedsAnAdministratorAndAValidForm(): void { self::ensureKernelShutdown(); $anonymous = self::createClient(); $anonymous->followRedirects(false); $anonymous->request('POST', '/admin/aufgaben/ausfuehren'); self::assertSame('http://localhost/admin/login', $anonymous->getResponse()->headers->get('Location')); $editor = $this->login('editor'); $editor->request('GET', '/admin/aufgaben'); self::assertSame(403, $editor->getResponse()->getStatusCode(), 'Eine Redakteurin verwaltet den Plan nicht.'); $admin = $this->login(); $admin->request('POST', '/admin/aufgaben/ausfuehren', ['_csrf_token' => 'erfunden', 'name' => 'publish-due']); $admin->followRedirect(); self::assertStringContainsString('Sitzung ist abgelaufen', (string) $admin->getResponse()->getContent()); self::assertNull($this->db->fetchOne('SELECT last_status FROM scheduled_task WHERE name = ?', ['publish-due'])); } public function testAnAdministratorCanRunATaskFromTheScreen(): void { $client = $this->login(); $crawler = $client->request('GET', '/admin/aufgaben'); $client->submit($crawler->filter('#run-publish-due')->form()); $client->followRedirect(); self::assertStringContainsString('Geplante Veröffentlichungen', (string) $client->getResponse()->getContent()); $row = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['publish-due']); self::assertSame('ok', $row['last_status']); self::assertSame('hand', $row['last_trigger']); self::assertNull($row['locked_until'], 'Nach dem Lauf ist die Sperre weg - sonst blockiert ein Klick die Aufgabe für eine Viertelstunde.'); } /** Der zweite Auslöser: das geöffnete Backend. Er gehört jedem angemeldeten Konto, nicht nur der Verwaltung. */ public function testTheOpenBackendDrivesThePlan(): void { $editor = $this->login('editor'); $editor->request('GET', '/admin'); $token = $this->tokenFrom((string) $editor->getResponse()->getContent()); self::assertNotSame('', $token, 'Jeder Bildschirm der Verwaltung bringt den Takt mit.'); $editor->request('POST', '/admin/aufgaben/takt', ['_csrf_token' => $token]); self::assertSame(200, $editor->getResponse()->getStatusCode()); $answer = json_decode((string) $editor->getResponse()->getContent(), true); self::assertIsArray($answer); self::assertArrayHasKey('ran', $answer); // Ohne gültiges Formular passiert nichts, auch für ein angemeldetes Konto nicht. $editor->request('POST', '/admin/aufgaben/takt', ['_csrf_token' => 'erfunden']); self::assertSame(403, $editor->getResponse()->getStatusCode()); } /** Der dritte Auslöser: ein Aufruf von außen, der nur mit dem richtigen Schlüssel etwas tut. */ public function testTheUrlCronAnswersOnlyToItsKey(): void { self::ensureKernelShutdown(); $client = self::createClient(); $client->request('GET', '/cms-cron/'.str_repeat('a', 40)); self::assertSame(404, $client->getResponse()->getStatusCode(), 'Ein falscher Schlüssel erfährt nicht einmal, dass es die Stelle gibt.'); $client->request('GET', '/cms-cron/'.Scheduler::cronToken(self::SECRET)); self::assertSame(200, $client->getResponse()->getStatusCode()); self::assertStringContainsString('text/plain', (string) $client->getResponse()->headers->get('Content-Type')); self::assertStringContainsString('noindex', (string) $client->getResponse()->headers->get('X-Robots-Tag')); self::assertStringContainsString('Aufgabe(n)', (string) $client->getResponse()->getContent()); } private function tokenFrom(string $html): string { return 1 === preg_match('#followRedirects(false); $crawler = $client->request('GET', '/admin/login'); $client->submit($crawler->selectButton('Anmelden')->form(['_username' => $username, '_password' => self::FIXTURE_PASSWORD])); $client->followRedirect(); return $client; } }