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,259 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Ops\Scheduler;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Der Aufgabenplan - und zwar die Eigenschaften, an denen er auf einem echten Hosting scheitern
|
||||
* würde: läuft eine Aufgabe zweimal, wenn zwei Besucher gleichzeitig kommen? Reißt eine kaputte
|
||||
* Aufgabe die Website mit? Fängt eine, die nicht laufen kann, bei jedem Seitenaufruf neu an?
|
||||
*/
|
||||
final class SchedulerTest extends TestCase
|
||||
{
|
||||
private Connection $db;
|
||||
private string $databaseFile;
|
||||
private string $projectDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->databaseFile = tempnam(sys_get_temp_dir(), 'kant-tasks-');
|
||||
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->databaseFile));
|
||||
TestSchema::build($this->db);
|
||||
$this->projectDir = sys_get_temp_dir().'/kant-tasks-'.bin2hex(random_bytes(6));
|
||||
mkdir($this->projectDir.'/var', 0o775, true);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->db->close();
|
||||
gc_collect_cycles();
|
||||
@unlink($this->databaseFile);
|
||||
foreach (glob($this->projectDir.'/var/*') ?: [] as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
@rmdir($this->projectDir.'/var');
|
||||
@rmdir($this->projectDir);
|
||||
}
|
||||
|
||||
/** @param list<SpyTask> $tasks */
|
||||
private function scheduler(array $tasks): Scheduler
|
||||
{
|
||||
return new Scheduler($this->db, $tasks, $this->projectDir);
|
||||
}
|
||||
|
||||
private function makeDue(string $name): void
|
||||
{
|
||||
$this->db->executeStatement('UPDATE scheduled_task SET next_run_at = ? WHERE name = ?', ['2020-01-01 00:00:00', $name]);
|
||||
}
|
||||
|
||||
public function testTheCodeDecidesWhichTasksExist(): void
|
||||
{
|
||||
$this->scheduler([new SpyTask('a'), new SpyTask('b')])->sync();
|
||||
self::assertSame(['a', 'b'], $this->db->fetchFirstColumn('SELECT name FROM scheduled_task ORDER BY name'));
|
||||
|
||||
// Nach einem Update kennt der Code eine Aufgabe weniger: ihre Zeile geht mit.
|
||||
$this->scheduler([new SpyTask('a')])->sync();
|
||||
self::assertSame(['a'], $this->db->fetchFirstColumn('SELECT name FROM scheduled_task'));
|
||||
}
|
||||
|
||||
public function testANewTaskDoesNotRunInTheVeryNextRequest(): void
|
||||
{
|
||||
// Sonst faellt direkt nach einem Update alles gleichzeitig an - im ersten Aufruf danach.
|
||||
$task = new SpyTask('a');
|
||||
self::assertSame([], $this->scheduler([$task])->runDue('besuch'));
|
||||
self::assertSame(0, $task->runs);
|
||||
}
|
||||
|
||||
public function testWhatIsDueRunsAndIsThenRescheduled(): void
|
||||
{
|
||||
$task = new SpyTask('a', 600);
|
||||
$scheduler = $this->scheduler([$task]);
|
||||
$scheduler->sync();
|
||||
$this->makeDue('a');
|
||||
|
||||
$results = $scheduler->runDue('besuch');
|
||||
self::assertCount(1, $results);
|
||||
self::assertSame('ok', $results[0]['status']);
|
||||
self::assertSame('Lauf 1', $results[0]['message']);
|
||||
self::assertSame(1, $task->runs);
|
||||
|
||||
// Danach ist sie nicht mehr faellig, und ein zweiter Aufruf laesst sie in Ruhe.
|
||||
self::assertSame([], $scheduler->runDue('besuch'));
|
||||
self::assertSame(1, $task->runs);
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['a']);
|
||||
self::assertSame('besuch', $row['last_trigger']);
|
||||
self::assertGreaterThan(gmdate('Y-m-d H:i:s'), (string) $row['next_run_at']);
|
||||
self::assertNull($row['locked_until']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der eigentliche Zweck der Sperre: zwei Besucher im selben Moment.
|
||||
*
|
||||
* Nachgestellt wird der Zustand, den der erste hinterlaesst, waehrend er noch arbeitet -
|
||||
* faellig, aber gesperrt. Der zweite muss daran vorbeigehen.
|
||||
*/
|
||||
public function testALockedTaskIsLeftAloneByTheNextRequest(): void
|
||||
{
|
||||
$task = new SpyTask('a');
|
||||
$scheduler = $this->scheduler([$task]);
|
||||
$scheduler->sync();
|
||||
$this->db->executeStatement(
|
||||
'UPDATE scheduled_task SET next_run_at = ?, locked_until = ? WHERE name = ?',
|
||||
['2020-01-01 00:00:00', gmdate('Y-m-d H:i:s', time() + 300), 'a'],
|
||||
);
|
||||
|
||||
self::assertSame([], $scheduler->runDue('besuch'));
|
||||
self::assertSame(0, $task->runs);
|
||||
}
|
||||
|
||||
/** Eine Sperre, deren Prozess das Hosting abgeschossen hat, blockiert nicht fuer immer. */
|
||||
public function testAnExpiredLockIsTakenOver(): void
|
||||
{
|
||||
$task = new SpyTask('a');
|
||||
$scheduler = $this->scheduler([$task]);
|
||||
$scheduler->sync();
|
||||
$this->db->executeStatement(
|
||||
'UPDATE scheduled_task SET next_run_at = ?, locked_until = ? WHERE name = ?',
|
||||
['2020-01-01 00:00:00', '2020-01-01 00:15:00', 'a'],
|
||||
);
|
||||
|
||||
self::assertCount(1, $scheduler->runDue('besuch'));
|
||||
self::assertSame(1, $task->runs);
|
||||
}
|
||||
|
||||
public function testAFailingTaskIsRecordedAndWaitsLongerEachTime(): void
|
||||
{
|
||||
$task = new SpyTask('a', 300, new \RuntimeException('Kein Netz.'));
|
||||
$scheduler = $this->scheduler([$task]);
|
||||
$scheduler->sync();
|
||||
$this->makeDue('a');
|
||||
|
||||
$results = $scheduler->runDue('besuch');
|
||||
self::assertSame('fehler', $results[0]['status']);
|
||||
self::assertStringContainsString('Kein Netz.', $results[0]['message']);
|
||||
$first = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['a']);
|
||||
self::assertSame(1, (int) $first['failures']);
|
||||
// 300 Sekunden Abstand, ein Fehlschlag: der naechste Versuch liegt weiter weg als der
|
||||
// gewoehnliche Abstand, aber die Sperre ist weg - die Aufgabe haengt nicht fest.
|
||||
self::assertNull($first['locked_until']);
|
||||
self::assertGreaterThan(gmdate('Y-m-d H:i:s', time() + 400), (string) $first['next_run_at']);
|
||||
|
||||
$this->makeDue('a');
|
||||
$scheduler->runDue('besuch');
|
||||
$second = $this->db->fetchAssociative('SELECT * FROM scheduled_task WHERE name = ?', ['a']);
|
||||
self::assertSame(2, (int) $second['failures']);
|
||||
self::assertGreaterThan((string) $first['next_run_at'], (string) $second['next_run_at']);
|
||||
}
|
||||
|
||||
public function testASwitchedOffTaskNeverRuns(): void
|
||||
{
|
||||
$task = new SpyTask('a');
|
||||
$scheduler = $this->scheduler([$task]);
|
||||
$scheduler->sync();
|
||||
$scheduler->enable('a', false);
|
||||
$this->makeDue('a');
|
||||
|
||||
self::assertSame([], $scheduler->runDue('besuch'));
|
||||
self::assertSame(0, $task->runs);
|
||||
|
||||
// Von Hand gestartet laeuft sie trotzdem: wer den Knopf drueckt, meint sie auch.
|
||||
$result = $scheduler->runNow('a');
|
||||
self::assertNotNull($result);
|
||||
self::assertSame(1, $task->runs);
|
||||
}
|
||||
|
||||
public function testOneRequestDoesNotRunEverythingAtOnce(): void
|
||||
{
|
||||
$tasks = [new SpyTask('a'), new SpyTask('b'), new SpyTask('c')];
|
||||
$scheduler = $this->scheduler($tasks);
|
||||
$scheduler->sync();
|
||||
foreach (['a', 'b', 'c'] as $name) {
|
||||
$this->makeDue($name);
|
||||
}
|
||||
|
||||
self::assertCount(2, $scheduler->runDue('besuch', 2));
|
||||
self::assertSame(2, array_sum(array_map(static fn (SpyTask $t): int => $t->runs, $tasks)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Der schnelle Weg: eine Datei sagt, wann sich das Fragen der Datenbank ueberhaupt lohnt.
|
||||
*
|
||||
* Ohne diese Eigenschaft kostet der Aufgabenplan jeden Seitenaufruf eine Abfrage - genau das,
|
||||
* was auf geteiltem Hosting am meisten weh tut.
|
||||
*/
|
||||
public function testTheStateFileHoldsTheNextMomentAnythingIsDue(): void
|
||||
{
|
||||
$scheduler = $this->scheduler([new SpyTask('a', 600)]);
|
||||
$scheduler->sync();
|
||||
|
||||
$state = $scheduler->fastState();
|
||||
self::assertTrue($state['traffic']);
|
||||
self::assertNotNull($state['next']);
|
||||
self::assertGreaterThan(time(), $state['next']);
|
||||
self::assertFileExists($this->projectDir.'/'.Scheduler::STATE_FILE);
|
||||
|
||||
// Die Schalter ueberleben das Nachtragen des naechsten Zeitpunkts.
|
||||
$scheduler->setWorkers(false, true);
|
||||
$this->makeDue('a');
|
||||
$scheduler->runDue('besuch');
|
||||
self::assertFalse($scheduler->fastState()['traffic']);
|
||||
self::assertTrue($scheduler->fastState()['admin']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waehrend eine Aufgabe laeuft, schickt die Zustandsdatei die uebrigen Anfragen weg.
|
||||
*
|
||||
* Sonst waere jeder Seitenaufruf waehrend einer langen Sicherung wieder bei der Datenbank:
|
||||
* faellig ist sie ja, sie ist nur schon vergeben.
|
||||
*/
|
||||
public function testARunningTaskKeepsTheOtherRequestsOutOfTheDatabase(): void
|
||||
{
|
||||
$scheduler = $this->scheduler([new SpyTask('a')]);
|
||||
$scheduler->sync();
|
||||
$lockEnds = gmdate('Y-m-d H:i:s', time() + 600);
|
||||
$this->db->executeStatement(
|
||||
'UPDATE scheduled_task SET next_run_at = ?, locked_until = ? WHERE name = ?',
|
||||
['2020-01-01 00:00:00', $lockEnds, 'a'],
|
||||
);
|
||||
|
||||
$scheduler->runDue('besuch');
|
||||
self::assertSame(strtotime($lockEnds.' UTC'), $scheduler->fastState()['next']);
|
||||
}
|
||||
|
||||
/** Zwischen hochgeladenem Code und ausgefuehrter Migration darf nichts explodieren. */
|
||||
public function testAMissingTableIsNotAnError(): void
|
||||
{
|
||||
$this->db->executeStatement('DROP TABLE scheduled_task');
|
||||
$task = new SpyTask('a');
|
||||
|
||||
self::assertSame([], $this->scheduler([$task])->runDue('besuch'));
|
||||
self::assertSame([], $this->scheduler([$task])->overview());
|
||||
self::assertSame(0, $task->runs);
|
||||
}
|
||||
|
||||
public function testTheProtocolKeepsWhatHappened(): void
|
||||
{
|
||||
$scheduler = $this->scheduler([new SpyTask('a')]);
|
||||
$scheduler->sync();
|
||||
$scheduler->runNow('a');
|
||||
|
||||
$lines = $scheduler->protocol();
|
||||
self::assertCount(1, $lines);
|
||||
self::assertStringContainsString('a', $lines[0]);
|
||||
self::assertStringContainsString('ok', $lines[0]);
|
||||
self::assertStringContainsString('hand', $lines[0]);
|
||||
}
|
||||
|
||||
public function testTheCronKeyDependsOnTheApplicationSecret(): void
|
||||
{
|
||||
$one = Scheduler::cronToken('geheimnis-eins');
|
||||
$two = Scheduler::cronToken('geheimnis-zwei');
|
||||
self::assertSame(40, strlen($one));
|
||||
self::assertNotSame($one, $two);
|
||||
self::assertSame($one, Scheduler::cronToken('geheimnis-eins'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Ops\Tasks\Task;
|
||||
|
||||
/**
|
||||
* Eine Aufgabe, die nichts tut außer mitzählen - oder auf Wunsch zu scheitern.
|
||||
*
|
||||
* Der Plan lässt sich nur dann ehrlich prüfen, wenn die Aufgabe selbst nichts kann: was hier
|
||||
* gemessen wird, ist die Mechanik ringsum - Fälligkeit, Sperre, Abstand nach einem Fehlschlag.
|
||||
*/
|
||||
final class SpyTask implements Task
|
||||
{
|
||||
public int $runs = 0;
|
||||
|
||||
public function __construct(
|
||||
private string $name,
|
||||
private int $interval = 300,
|
||||
private ?\Throwable $error = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Testaufgabe '.$this->name;
|
||||
}
|
||||
|
||||
public function explanation(): string
|
||||
{
|
||||
return 'Nur für Tests.';
|
||||
}
|
||||
|
||||
public function interval(): int
|
||||
{
|
||||
return $this->interval;
|
||||
}
|
||||
|
||||
public function run(): string
|
||||
{
|
||||
++$this->runs;
|
||||
if (null !== $this->error) {
|
||||
throw $this->error;
|
||||
}
|
||||
|
||||
return 'Lauf '.$this->runs;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ use DoctrineMigrations\Version20260912001100;
|
||||
use DoctrineMigrations\Version20260912001200;
|
||||
use DoctrineMigrations\Version20260912001300;
|
||||
use DoctrineMigrations\Version20260913000100;
|
||||
use DoctrineMigrations\Version20260914000100;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
@@ -47,6 +48,7 @@ final class TestSchema
|
||||
Version20260912001200::class,
|
||||
Version20260912001300::class,
|
||||
Version20260913000100::class,
|
||||
Version20260914000100::class,
|
||||
] as $migration) {
|
||||
(new $migration($db, new NullLogger()))->up($schema);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user