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