Fassung 1.6.0: der Aufgabenplan wird veroeffentlicht
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s

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:
kantarciandClaude Opus 5 committed 2026-09-14 08:30:13 +02:00
1 parent d1ac9d760b
commit 05ea703655
35 files changed
+2229 -13

No files matched your search

+168
View File
@@ -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;
}
}