Files
KantSites/tests/TaskScreenTest.php
T
kantarciandClaude Opus 5.5 723680b7de Add the central site registry and remote updates
Every installation now reports address and version to sites.kant-it.de
every 15 minutes and picks up update orders there (ADR 123). Reports and
answers are HMAC-signed with a per-installation key sent on first
contact; an order can only install a published release with checksum.
The next request after the file swap runs the migrations, so a remote
update finishes without anyone in the admin.

The platform itself lives in zentrale/ (plain PHP + SQLite, not part of
the release archive): overview with outdated, silent and unassigned
installations, single and bulk updates, per-site detail and history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:43:13 +02:00

169 lines
7.8 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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', '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('#<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;
}
}