Files
KantSites/tests/BackupTest.php
T
kantarciandClaude Fable 5.2 ead47cc924 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>
2026-11-10 11:41:05 +01:00

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);
}
}