Files
KantSites/tests/BackupTest.php
T
kantarciandClaude Opus 5 d295757ce0
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Fassung 1.4.5: drei Fehler, die nur auf fremden Servern auftraten
Alle drei von Umut auf seinem Hosting gefunden, keiner davon auf
meinem Rechner sichtbar.

- Der Updateknopf endete mit 500. Das Update lief vollstaendig durch -
  alle neun Schritte, Dateien getauscht, var/ und .env unberuehrt -,
  aber die Ergebnisseite starb daran: der Updater ueber Git nennt
  einen Schritt "step", der ueber das Archiv nannte ihn "label", beide
  legen ihn unter demselben Sitzungsschluessel ab, und die Vorlage
  liest "step". Damit war auch der zweite Knopf unerreichbar. Zehn
  Tests prueften das Rueckgabe-Array, keiner die Seite, die es
  anzeigt; der neue Test rendert sie (ADR 071).
- Die Verwaltung zeigte Git-Fehler auf einer Installation ohne Git.
  "git ist installiert" und "das hier ist eine Arbeitskopie" waren
  derselbe Schalter; auf dem Hosting ist Git vorhanden, die
  Installation kam aber aus dem Archiv. Dort stand "fatal: Not a git
  repository" an der Stelle der Fassungsnummer. Ohne Arbeitskopie
  verschwinden die Git-Karten jetzt (ADR 072).
- "Sicherung anlegen" endete mit 500: VACUUM INTO gibt es erst seit
  SQLite 3.27, das Hosting hat eine aeltere Fassung - alt genug fuer
  die Installation, zu alt fuer die Sicherung. Bei aelteren wird jetzt
  unter einer Schreibsperre kopiert, und der Knopf faengt jeden Fehler
  ab statt mit einer 500-Seite zu enden (ADR 072).

Nachgestellt wurde das mit einer echten 1.4.3-Installation aus dem
Releasearchiv, eigener Datenbank, eigenem Port. Der Archiv-Updater
laeuft dort vollstaendig durch: 1.4.3 -> 1.4.4, neun Schritte, var/
und .env unberuehrt.

Geprueft: 433 Tests auf PHP 8.2 und 8.4, Editor-Browsertest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:54:13 +02:00

246 lines
10 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 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);
}
}