Keep old pages readable, detect stale settings writes, make backups honest
- Own CSS is parsed on write only. A page whose stored CSS no longer passes a stricter rule answers 200 and opens in the editor; that one block loses its CSS. A document read several times in one request is validated once. - Settings and theme refuse a write from an outdated form. - A backup fails when a media file does not arrive completely and leaves no half directory behind; rotation counts intact backups only. - The archive update takes its backup behind the maintenance page. - A package import puts all media in place before removing the old ones, reports honestly when that fails and keeps a maintenance state that was already on. - Restoring replaces the SQLite file in one move and points at pending migrations. - Six irreversible actions ask first, in a dialog of the admin's own. - Ctrl+S in the editor takes the field being typed in along; Delete no longer removes a block while a dialog is open. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
0d61aa0f22
commit
ead47cc924
25 files changed
+437
-72
No files matched your search
@@ -95,7 +95,7 @@ final class ArchiveUpdateTest extends TestCase
|
||||
self::assertFileExists($this->root.'/var/cache/prod/running-service.php', 'The update request still loads compiled services after replacing files.');
|
||||
self::assertTrue($ergebnis['migrate'] ?? false, 'Nach dem Ersetzen fehlen noch die Migrationen.');
|
||||
self::assertSame(
|
||||
['Fassung ermitteln', 'Sicherung', 'Archiv laden', 'Prüfsumme', 'Entpacken', 'Wartungszustand', 'Dateien ersetzen', 'Zwischenspeicher', 'Dateien sind ersetzt'],
|
||||
['Fassung ermitteln', 'Archiv laden', 'Prüfsumme', 'Entpacken', 'Wartungszustand', 'Sicherung', 'Dateien ersetzen', 'Zwischenspeicher', 'Dateien sind ersetzt'],
|
||||
array_column($ergebnis['steps'], 'step'),
|
||||
'Die Reihenfolge ist der Punkt: gesichert wird vor dem ersten Handgriff, geprüft vor dem Entpacken.',
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Content\PostRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\StaleWrite;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Kernel;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
@@ -163,6 +164,65 @@ final class BacklogTest extends TestCase
|
||||
self::assertStringContainsString('Dieses Feld wird benötigt.', $antwort->getContent());
|
||||
}
|
||||
|
||||
public function testAPageWhoseStoredCssNoLongerPassesStaysReachableAndEditable(): void
|
||||
{
|
||||
// Gespeichert wurde, was beim Speichern galt. Ist eine Regel inzwischen strenger, fällt das
|
||||
// CSS dieses einen Blocks weg (ADR 116) - die Seite antwortet nicht mit 500 und lässt sich
|
||||
// zum Reparieren öffnen. Vorher warf schon das Lesen.
|
||||
$document = DemoContent::starter('Altbestand');
|
||||
$document['blocks'][] = ['id' => 'alt-text', 'type' => 'text', 'props' => ['text' => 'Bleibt lesbar'], 'css' => 'color: red;'];
|
||||
$this->publishHome($document);
|
||||
$alt = '& ~ p { color: red }';
|
||||
try {
|
||||
(new DocumentValidator())->validate([...$document, 'schemaVersion' => 23, 'blocks' => [...array_slice($document['blocks'], 0, -1), ['id' => 'alt-text', 'type' => 'text', 'props' => ['text' => 'Bleibt lesbar'], 'css' => $alt]]], writing: true);
|
||||
self::fail('Dieses CSS muss beim Speichern abgelehnt werden, sonst prüft der Test nichts.');
|
||||
} catch (InvalidDocument) {
|
||||
}
|
||||
foreach ($this->db->fetchAllAssociative('SELECT id, document FROM revision WHERE page_id = ?', ['page-1']) as $fassung) {
|
||||
$this->db->update('revision', ['document' => str_replace('color: red;', $alt, $fassung['document'])], ['id' => $fassung['id']]);
|
||||
}
|
||||
|
||||
$antwort = $this->request('/');
|
||||
self::assertSame(200, $antwort->getStatusCode());
|
||||
self::assertStringContainsString('Bleibt lesbar', $antwort->getContent());
|
||||
self::assertStringNotContainsString('~ p', $antwort->getContent(), 'Das abgelehnte CSS wird nicht ausgegeben.');
|
||||
$entwurf = $this->pages->findForEditor('page-1');
|
||||
self::assertSame($alt, end($entwurf['document']['blocks'])['css'], 'Im Editor steht es noch da und lässt sich korrigieren.');
|
||||
|
||||
// Speichern verlangt weiter gültiges CSS.
|
||||
$this->expectException(InvalidDocument::class);
|
||||
$this->pages->saveDraft('page-1', $entwurf['document'], $entwurf['version']);
|
||||
}
|
||||
|
||||
public function testSettingsAndThemeRefuseAWriteFromAnOutdatedForm(): void
|
||||
{
|
||||
// Zwei Admins haben dasselbe Formular offen. Der zweite überschrieb bisher still, was der
|
||||
// erste gespeichert hatte. Jetzt trägt das Formular eine Kennung des geladenen Stands.
|
||||
$werte = ['legalName' => 'Erste GmbH'];
|
||||
$geladen = $this->site->stamp();
|
||||
$this->site->save($werte, $geladen);
|
||||
self::assertNotSame($geladen, $this->site->stamp());
|
||||
try {
|
||||
$this->site->save([...$werte, 'legalName' => 'Zweite GmbH'], $geladen);
|
||||
self::fail('Der veraltete Stand muss abgelehnt werden.');
|
||||
} catch (StaleWrite) {
|
||||
}
|
||||
self::assertSame('Erste GmbH', $this->site->load()['legalName']);
|
||||
$this->site->save([...$werte, 'legalName' => 'Zweite GmbH'], $this->site->stamp());
|
||||
self::assertSame('Zweite GmbH', $this->site->load()['legalName']);
|
||||
|
||||
$theme = new Theme($this->db);
|
||||
$stand = $theme->load();
|
||||
$geladen = $theme->stamp();
|
||||
$theme->save($stand['colors'], $stand['fonts'], [...$stand['base'], 'linkUnderline' => null], null, $geladen);
|
||||
try {
|
||||
$theme->save($stand['colors'], $stand['fonts'], $stand['base'], null, $geladen);
|
||||
self::fail('Der veraltete Stand muss abgelehnt werden.');
|
||||
} catch (StaleWrite) {
|
||||
}
|
||||
self::assertFalse($theme->load()['base']['linkUnderline'], 'Die erste Änderung bleibt.');
|
||||
}
|
||||
|
||||
public function testAFormInTheFooterCanBeSentFromAnArchive(): void
|
||||
{
|
||||
(new FormRepository($this->db))->save('kontakt', 'Kontakt', [
|
||||
|
||||
@@ -196,6 +196,30 @@ final class BackupTest extends TestCase
|
||||
self::assertSame([$names[1], $names[0]], $removed);
|
||||
}
|
||||
|
||||
public function testADamagedBackupNeitherCountsAsIntactNorPushesOutACompleteOne(): void
|
||||
{
|
||||
$alt = $this->backup->create('Test', new \DateTimeImmutable('2026-09-10 10:00:00', new \DateTimeZone('UTC')));
|
||||
$neu = $this->backup->create('Test', new \DateTimeImmutable('2026-09-11 10:00:00', new \DateTimeZone('UTC')));
|
||||
// Fehlt eine der gezählten Mediendateien, ist die Sicherung nicht mehr unversehrt.
|
||||
unlink($neu['path'].'/medien/bild-480.webp');
|
||||
// Ein abgebrochener Lauf von früher: ein Ordner ohne Verzeichnis, der neueste von allen.
|
||||
$halb = $this->backup->directory().'/sicherung-20260912-100000';
|
||||
mkdir($halb);
|
||||
touch($halb, time() - 7200);
|
||||
// Und einer von eben - das kann eine Sicherung sein, die gerade geschrieben wird.
|
||||
$laufend = $this->backup->directory().'/sicherung-20260913-100000';
|
||||
mkdir($laufend);
|
||||
touch($neu['path'], time() - 7200);
|
||||
|
||||
$zustand = array_column($this->backup->all(), 'intact', 'name');
|
||||
self::assertSame([basename($laufend) => false, basename($halb) => false, $neu['name'] => false, $alt['name'] => true], $zustand);
|
||||
|
||||
// Vorher zählten die beschädigten als die neuesten, und die vollständige flog raus.
|
||||
$entfernt = $this->backup->rotate(1);
|
||||
self::assertEqualsCanonicalizing([basename($halb), $neu['name']], $entfernt);
|
||||
self::assertSame([basename($laufend), $alt['name']], array_column($this->backup->all(), 'name'));
|
||||
}
|
||||
|
||||
public function testTwoBackupsInTheSameSecondGetTwoDirectories(): void
|
||||
{
|
||||
// This is not academic: a restore takes a safety copy, and that lands in the same second
|
||||
|
||||
@@ -90,6 +90,16 @@ final class SitePackageTest extends TestCase
|
||||
self::assertFileDoesNotExist($this->sites['ziel']['root'].'/var/maintenance');
|
||||
}
|
||||
|
||||
public function testAnImportLeavesAMaintenanceStateThatWasAlreadyOn(): void
|
||||
{
|
||||
// Wer die Wartung vorher von Hand eingeschaltet hat, will sie nach dem Import noch haben.
|
||||
file_put_contents($this->sites['ziel']['root'].'/var/maintenance', "an\n");
|
||||
$result = $this->sites['ziel']['package']->import($this->sites['quelle']['package']->export());
|
||||
self::assertTrue($result['ok'], json_encode($result['steps'], JSON_UNESCAPED_UNICODE));
|
||||
self::assertFileExists($this->sites['ziel']['root'].'/var/maintenance');
|
||||
self::assertSame([str_repeat('a', 32).'-480.webp'], array_values(array_diff(scandir($this->sites['ziel']['root'].'/var/media'), ['.', '..'])));
|
||||
}
|
||||
|
||||
public function testAccountsComeAlongOnlyWhenChosenOnBothSides(): void
|
||||
{
|
||||
// Paket ohne Konten, Häkchen gesetzt: abgelehnt, nichts geändert.
|
||||
|
||||
Reference in new issue
Block a user