Fassung 1.6.0: der Aufgabenplan wird veroeffentlicht
Wiederkehrende Aufgaben ohne Cronjob (ADR 073): Sicherung, Statistik kuerzen, geplante Veroeffentlichung, Updatepruefung und Aufraeumen laufen als Plan in der Tabelle scheduled_task - eine Zeile je Aufgabe mit Faelligkeit, Sperre und letztem Ergebnis. Auf geteiltem Hosting gibt es oft keinen Cronjob; der Plan haengt stattdessen an Anfragen und an einem Takt aus dem geoeffneten Backend. Diese Arbeit entstand in einer parallel laufenden Sitzung und lag bis hierher unveroeffentlicht im Arbeitsverzeichnis: die Releases 1.5.0 bis 1.5.5 wurden bewusst aus dem jeweiligen Tag gebaut und enthielten sie deshalb nicht. Vor der Veroeffentlichung geprueft, nicht bloss eingepackt: - phpunit 452/452, npm test 28/28, npm run test:editor vollstaendig. - Die Migration 20260914000100 legt nur die Tabelle scheduled_task an und nimmt sie beim Zurueckrollen wieder weg. Sie fasst keine bestehenden Daten an. - Seit einer Stunde keine Schreibzugriffe der anderen Sitzung mehr. Dabei zwei Fehler behoben: - config/services.yaml verwendet !tagged_iterator. Das ist gueltiges Symfony, aber "lint:yaml config" kennt eigene Tags nur mit --parse-tags und gab 1 zurueck - der CI-Lauf waere daran gescheitert. Flag ergaenzt. - AdminHeadersSubscriber.php (aus 1.5.3) entsprach nicht dem Stil von php-cs-fixer; composer lint gab 8 zurueck. Behoben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d1ac9d760b
commit
05ea703655
35 files changed
+2229
-13
No files matched your search
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Kernel;
|
||||
use App\Ops\Scheduler;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* Die drei Auslöser, wie sie über HTTP erreichbar sind.
|
||||
*
|
||||
* Geprüft wird nicht das Aussehen, sondern wer was darf: Der Bildschirm gehört Administratoren,
|
||||
* der Takt der Verwaltung braucht eine Anmeldung und ein gültiges Formular, und der URL-Cronjob
|
||||
* hängt an einem Schlüssel, der sich nicht erraten lässt. Jede dieser Stellen startet Arbeit auf
|
||||
* dem Server – eine offene wäre eine Einladung.
|
||||
*/
|
||||
final class TaskScreenTest extends WebTestCase
|
||||
{
|
||||
private const FIXTURE_PASSWORD = 'Fixture-only-password';
|
||||
private const SECRET = 'isolated-test-secret';
|
||||
|
||||
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-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'] 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(5, (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('#<meta name="kant-tick-token" content="([^"]+)"#', $html, $match) ? $match[1] : '';
|
||||
}
|
||||
|
||||
private function login(string $username = 'admin'): KernelBrowser
|
||||
{
|
||||
self::ensureKernelShutdown();
|
||||
$client = self::createClient();
|
||||
$client->followRedirects(false);
|
||||
$crawler = $client->request('GET', '/admin/login');
|
||||
$client->submit($crawler->selectButton('Anmelden')->form(['_username' => $username, '_password' => self::FIXTURE_PASSWORD]));
|
||||
$client->followRedirect();
|
||||
|
||||
return $client;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user