- 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>
270 lines
12 KiB
PHP
270 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Tests;
|
|
|
|
use App\Infrastructure\ConnectionFactory;
|
|
use App\Ops\Backup;
|
|
use App\Ops\CommandRunner;
|
|
use Doctrine\DBAL\Connection;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* Backups, and above all the half nobody practises: putting one back.
|
|
*
|
|
* The test that matters is testARestorePutsDeletedContentBack - everything else is bookkeeping.
|
|
* A backup function whose restore has never run is a guess.
|
|
*/
|
|
final class BackupTest extends TestCase
|
|
{
|
|
private string $root;
|
|
private Connection $db;
|
|
private Backup $backup;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->root = str_replace('\\', '/', sys_get_temp_dir()).'/kant-backup-'.bin2hex(random_bytes(6));
|
|
mkdir($this->root.'/var/media', 0o775, true);
|
|
file_put_contents($this->root.'/VERSION', "1.1.0\n");
|
|
// Not part of a backup, and the test says so below.
|
|
file_put_contents($this->root.'/.env', "APP_SECRET=geheim\n");
|
|
|
|
$url = 'sqlite:///'.$this->root.'/var/cms.db';
|
|
$this->db = ConnectionFactory::create($url);
|
|
TestSchema::build($this->db);
|
|
$this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']);
|
|
foreach (['p1' => 'home', 'p2' => 'leistungen'] as $id => $slug) {
|
|
$this->db->insert('page', ['id' => $id, 'slug' => $slug, 'path' => $slug, 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
|
}
|
|
file_put_contents($this->root.'/var/media/bild-480.webp', 'erstes bild');
|
|
|
|
$this->backup = new Backup($this->root, $url, $this->root.'/var/media', $this->db, new CommandRunner());
|
|
}
|
|
|
|
public function testABackupWorksOnAnOlderSqliteToo(): void
|
|
{
|
|
// Auf Umuts Hosting endete „Sicherung anlegen" mit 500. Der Grund: `VACUUM INTO` gibt es
|
|
// erst seit SQLite 3.27, und dort läuft eine ältere Fassung - alt genug für die
|
|
// Installation (die braucht 3.16), zu alt für die Sicherung. Statt VACUUM wird dann unter
|
|
// einer Schreibsperre kopiert. Geprüft wird das hier wirklich und nicht nur versprochen:
|
|
// die Schwelle wird so hoch gesetzt, dass der Ersatzweg läuft.
|
|
$url = 'sqlite:///'.str_replace('\\', '/', $this->root).'/var/cms.db';
|
|
$alt = new Backup($this->root, $url, $this->root.'/var/media', $this->db, new CommandRunner(), '99.0.0');
|
|
|
|
$sicherung = $alt->create('auf altem SQLite');
|
|
|
|
$datei = $sicherung['path'].'/cms.db';
|
|
self::assertFileExists($datei);
|
|
self::assertGreaterThan(0, (int) filesize($datei));
|
|
|
|
// Und es ist eine vollständige, lesbare Datenbank - nicht ein halb geschriebener Torso.
|
|
$kopie = new \PDO('sqlite:'.$datei);
|
|
self::assertSame(
|
|
(int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'),
|
|
(int) $kopie->query('SELECT COUNT(*) FROM revision')->fetchColumn(),
|
|
'Die Kopie muss denselben Inhalt haben wie das Original.',
|
|
);
|
|
self::assertSame(
|
|
$sicherung['manifest']['datenbank']['sha256'],
|
|
hash_file('sha256', $datei),
|
|
'Die Prüfsumme im Manifest gehört zu genau dieser Datei.',
|
|
);
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
$this->db->close();
|
|
gc_collect_cycles();
|
|
$this->remove($this->root);
|
|
}
|
|
|
|
public function testABackupHoldsTheDatabaseTheMediaAndAManifestButNoSecrets(): void
|
|
{
|
|
$result = $this->backup->create('Test');
|
|
$path = $result['path'];
|
|
|
|
self::assertFileExists($path.'/cms.db');
|
|
self::assertFileExists($path.'/medien/bild-480.webp');
|
|
self::assertFileExists($path.'/'.Backup::MANIFEST);
|
|
// The secret stays out: a backup gets downloaded and passed around.
|
|
self::assertFileDoesNotExist($path.'/.env');
|
|
self::assertFileDoesNotExist($path.'/.env.local');
|
|
|
|
$manifest = $result['manifest'];
|
|
self::assertSame('1.1.0', $manifest['fassung']);
|
|
self::assertSame(2, $manifest['inhalt']['seiten']);
|
|
self::assertSame(1, $manifest['medien']['dateien']);
|
|
self::assertSame(hash_file('sha256', $path.'/cms.db'), $manifest['datenbank']['sha256']);
|
|
self::assertContains('.env', $manifest['enthaelt_nicht']);
|
|
|
|
// The copy is a usable database, not a file that merely exists.
|
|
$copy = ConnectionFactory::create('sqlite:///'.$path.'/cms.db');
|
|
self::assertSame(2, (int) $copy->fetchOne('SELECT COUNT(*) FROM page'));
|
|
$copy->close();
|
|
|
|
$listed = $this->backup->all();
|
|
self::assertCount(1, $listed);
|
|
self::assertTrue($listed[0]['intact']);
|
|
self::assertGreaterThan(0, $listed[0]['bytes']);
|
|
}
|
|
|
|
public function testARestorePutsDeletedContentBack(): void
|
|
{
|
|
$backup = $this->backup->create('vor dem Unfall');
|
|
|
|
// The accident: a page and an image are gone.
|
|
$this->db->executeStatement('DELETE FROM page WHERE id = ?', ['p2']);
|
|
unlink($this->root.'/var/media/bild-480.webp');
|
|
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
|
|
|
$result = $this->backup->restore($backup['name']);
|
|
self::assertTrue($result['ok'], $this->explain($result));
|
|
|
|
// A fresh connection, because the file underneath was replaced.
|
|
$after = ConnectionFactory::create('sqlite:///'.$this->root.'/var/cms.db');
|
|
self::assertSame(2, (int) $after->fetchOne('SELECT COUNT(*) FROM page'));
|
|
self::assertSame('leistungen', $after->fetchOne('SELECT path FROM page WHERE id = ?', ['p2']));
|
|
$after->close();
|
|
self::assertFileExists($this->root.'/var/media/bild-480.webp');
|
|
|
|
// A safety copy of the state before the restore was taken first.
|
|
self::assertContains('Aktuellen Stand sichern', array_column($result['steps'], 'step'));
|
|
self::assertCount(2, $this->backup->all(), 'The safety copy is a backup of its own.');
|
|
}
|
|
|
|
public function testARestoreKeepsFilesThatWereAddedAfterTheBackup(): void
|
|
{
|
|
$backup = $this->backup->create('Test');
|
|
// Uploaded after the backup: a restore must not delete it, or the operation meant to
|
|
// prevent loss would cause one.
|
|
file_put_contents($this->root.'/var/media/spaeter-480.webp', 'neueres bild');
|
|
|
|
self::assertTrue($this->backup->restore($backup['name'])['ok']);
|
|
self::assertFileExists($this->root.'/var/media/spaeter-480.webp');
|
|
self::assertFileExists($this->root.'/var/media/bild-480.webp');
|
|
}
|
|
|
|
public function testATamperedBackupIsNotRestored(): void
|
|
{
|
|
$backup = $this->backup->create('Test');
|
|
file_put_contents($backup['path'].'/cms.db', 'kaputt');
|
|
|
|
self::assertFalse($this->backup->all()[0]['intact']);
|
|
$result = $this->backup->restore($backup['name']);
|
|
self::assertFalse($result['ok']);
|
|
self::assertSame('Unversehrt', $this->firstFailed($result)['step']);
|
|
// Nothing was touched - not even a safety copy, because it never got that far.
|
|
self::assertCount(1, $this->backup->all());
|
|
self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
|
}
|
|
|
|
public function testABackupFromANewerVersionIsRefusedUnlessForced(): void
|
|
{
|
|
$backup = $this->backup->create('Test');
|
|
// The installation goes back to an older version, as after a rollback.
|
|
file_put_contents($this->root.'/VERSION', "1.0.0\n");
|
|
|
|
$refused = $this->backup->restore($backup['name']);
|
|
self::assertFalse($refused['ok']);
|
|
self::assertSame('Fassung passt', $this->firstFailed($refused)['step']);
|
|
self::assertStringContainsString('Erst aktualisieren', $this->firstFailed($refused)['detail']);
|
|
|
|
// With force it runs, because sometimes that is exactly what is wanted.
|
|
self::assertTrue($this->backup->restore($backup['name'], true)['ok']);
|
|
}
|
|
|
|
public function testAMissingBackupIsSaidSoRatherThanGuessed(): void
|
|
{
|
|
$result = $this->backup->restore('sicherung-gibt-es-nicht');
|
|
self::assertFalse($result['ok']);
|
|
self::assertSame('Sicherung finden', $this->firstFailed($result)['step']);
|
|
}
|
|
|
|
public function testRotationKeepsTheNewestAndRemovesTheRest(): void
|
|
{
|
|
$names = [];
|
|
for ($index = 0; $index < 4; ++$index) {
|
|
$names[] = $this->backup->create('Test', new \DateTimeImmutable('2026-09-1'.$index.' 10:00:00', new \DateTimeZone('UTC')))['name'];
|
|
}
|
|
self::assertCount(4, $this->backup->all());
|
|
|
|
$removed = $this->backup->rotate(2);
|
|
$left = array_column($this->backup->all(), 'name');
|
|
self::assertCount(2, $left);
|
|
// The newest two survive; rotation without a limit would let the disk fill up and the
|
|
// next backup fail exactly when it is needed.
|
|
self::assertSame([$names[3], $names[2]], $left);
|
|
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
|
|
// as the backup it is about to restore.
|
|
$moment = new \DateTimeImmutable('2026-09-12 10:00:00', new \DateTimeZone('UTC'));
|
|
$first = $this->backup->create('Test', $moment);
|
|
$second = $this->backup->create('Test', $moment);
|
|
|
|
self::assertNotSame($first['name'], $second['name']);
|
|
self::assertSame('sicherung-20260912-100000', $first['name']);
|
|
self::assertSame('sicherung-20260912-100000-2', $second['name']);
|
|
self::assertCount(2, $this->backup->all());
|
|
}
|
|
|
|
private function firstFailed(array $result): array
|
|
{
|
|
foreach ($result['steps'] as $step) {
|
|
if (!$step['ok']) {
|
|
return $step;
|
|
}
|
|
}
|
|
self::fail('Kein gescheiterter Schritt: '.$this->explain($result));
|
|
}
|
|
|
|
private function explain(array $result): string
|
|
{
|
|
return implode("\n", array_map(
|
|
static fn (array $step): string => sprintf('%s %s: %s', $step['ok'] ? 'ok' : 'FEHLER', $step['step'], $step['detail']),
|
|
$result['steps'],
|
|
));
|
|
}
|
|
|
|
private function remove(string $path): void
|
|
{
|
|
if (!is_dir($path)) {
|
|
@unlink($path);
|
|
|
|
return;
|
|
}
|
|
foreach (scandir($path) ?: [] as $entry) {
|
|
if ('.' !== $entry && '..' !== $entry) {
|
|
$this->remove($path.'/'.$entry);
|
|
}
|
|
}
|
|
@rmdir($path);
|
|
}
|
|
}
|