Arbeitspaket O. Der Teil, um den es geht, ist nicht das Sichern, sondern das Zurueckspielen: eine Sicherungsfunktion, deren Wiederherstellung nie gelaufen ist, ist eine Vermutung. Die Datenbank wird nicht kopiert, sondern von SQLite ausgeschrieben (VACUUM INTO). Ein copy() waehrend eines Schreibvorgangs ergibt eine Datei, die vorhanden aussieht und sich am Tag der Wiederherstellung nicht oeffnen laesst. Der Medienordner gehoert dazu - ohne die Dateien stellt die Sicherung eine Website voller kaputter Bilder her. .env gehoert nicht dazu: darin steht APP_SECRET, und eine Sicherung wird heruntergeladen und weitergereicht. Was nicht drin ist, steht im Manifest. Jede Sicherung traegt Zeitpunkt, Anlass, Fassung, Migrationsstand, Inhaltszahlen und die SHA-256 der Datenbank. Die Uebersicht zeigt "unversehrt" oder "Pruefsumme falsch" - eine Sicherung, von der man erst beim Zurueckspielen erfaehrt, dass sie kaputt ist, waere schlimmer als keine. Beim Zurueckspielen: neuere Fassung wird abgewiesen (mit --force geht es), zuerst wird der aktuelle Stand gesichert, Wartungszustand an, Medien werden ergaenzt statt ersetzt - eine Datei, die nach der Sicherung hochgeladen wurde, bleibt liegen. Die Massnahme gegen Datenverlust darf nicht selbst welchen verursachen. In der Verwaltung gibt es Liste und Anlegen-Knopf, aber kein Zurueckspielen: ein Knopf, der die ganze Datenbank ersetzt, gehoert nicht neben einen, der eine Seite speichert. Nachgewiesen, zweimal: acht Tests gegen eine wegwerfbare Installation (Sicherung, Seite und Bilddatei loeschen, zurueckspielen, beides wieder da) - und von Hand auf der Entwicklungsinstallation mit einer eigens angelegten Testseite: 5 Seiten, gesichert, geloescht (4), zurueckgespielt (5, Seite mit ihrer Adresse wieder da), Testseite entfernt (4). Damit ist der aelteste offene Punkt aus Paket I erledigt. Ein Fall, den erst der Test zeigte: zwei Sicherungen in derselben Sekunde - nicht akademisch, denn die Wiederherstellung legt selbst eine Sicherheitskopie an. Der Name bekommt jetzt eine Nummer, statt dass die zweite scheitert. Offen: MySQL (mysqldump fehlt) und ein Download ueber den Browser. 344 PHP-Tests / 1397 Assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
216 lines
8.9 KiB
PHP
216 lines
8.9 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());
|
|
}
|
|
|
|
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 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);
|
|
}
|
|
}
|