*/ private array $sites = []; protected function setUp(): void { foreach (['quelle', 'ziel'] as $name) { $root = str_replace('\\', '/', sys_get_temp_dir()).'/kant-paket-'.$name.'-'.bin2hex(random_bytes(5)); mkdir($root.'/var/media', 0o775, true); file_put_contents($root.'/VERSION', "1.32.0\n"); $url = 'sqlite:///'.$root.'/var/cms.db'; $db = ConnectionFactory::create($url); TestSchema::build($db); $db->insert('website', ['id' => 'default', 'name' => 'Website '.$name, 'default_locale' => 'de', 'theme' => '{}', 'settings' => json_encode(['turnstileSecret' => 'geheim-'.$name, 'turnstileSiteKey' => 'schluessel-'.$name])]); $pages = new PageRepository($db, new DocumentValidator(), new SiteSettings($db, new MediaLibrary($db, $root.'/var/media'))); $pages->createPage('ziel' === $name ? 'alte-seite' : 'neue-seite', null, 'page', DemoContent::starter('Seite der '.$name)); (new FormRepository($db))->save('anfrage', 'Anfrage', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true]], ['recipient' => 'info@example.org'], 0); $db->insert('cms_user', ['id' => 'u-'.$name, 'username' => $name, 'password_hash' => 'hash-'.$name, 'roles' => '["ROLE_ADMIN"]']); file_put_contents($root.'/var/media/'.str_repeat('ziel' === $name ? 'b' : 'a', 32).'-480.webp', 'bild '.$name); $runner = new CommandRunner(); $this->sites[$name] = ['root' => $root, 'db' => $db, 'package' => new SitePackage($db, new DocumentValidator(), new Backup($root, $url, $root.'/var/media', $db, $runner), new Updater($root, $url, $runner, $db), $root, $root.'/var/media')]; } // Eine Anfrage, die es nur auf der Zielwebsite gibt - sie muss den Import überstehen. $ziel = $this->sites['ziel']['db']; $ziel->insert('form_submission', ['id' => 's1', 'form_id' => $ziel->fetchOne('SELECT id FROM form'), 'data' => '{}', 'sender_hash' => 'x', 'created_at' => '2026-10-12 10:00:00', 'delivered' => 0]); } protected function tearDown(): void { foreach ($this->sites as $site) { $site['db']->close(); } gc_collect_cycles(); foreach ($this->sites as $site) { self::remove($site['root']); } } public function testImportReplacesContentAndKeepsAccountsSubmissionsAndServiceKeys(): void { $file = $this->sites['quelle']['package']->export(); $result = $this->sites['ziel']['package']->import($file); self::assertTrue($result['ok'], json_encode($result['steps'], JSON_UNESCAPED_UNICODE)); $db = $this->sites['ziel']['db']; self::assertSame(['neue-seite'], $db->fetchFirstColumn('SELECT path FROM page')); self::assertSame('Website quelle', $db->fetchOne('SELECT name FROM website')); // Das Konto der Zielwebsite bleibt - genau der Anlass dieses Imports. self::assertSame([['username' => 'ziel', 'password_hash' => 'hash-ziel']], $db->fetchAllAssociative('SELECT username, password_hash FROM cms_user')); // Die Anfrage hängt jetzt am Formular aus dem Paket (gleiche Adresse, neue Kennung). self::assertSame($db->fetchOne('SELECT id FROM form'), $db->fetchOne('SELECT form_id FROM form_submission WHERE id = ?', ['s1'])); self::assertSame($this->sites['quelle']['db']->fetchOne('SELECT id FROM form'), $db->fetchOne('SELECT id FROM form')); // Zugangsdaten zu Diensten bleiben, wo sie waren - und standen auch nicht im Paket. $settings = json_decode((string) $db->fetchOne('SELECT settings FROM website'), true); self::assertSame(['geheim-ziel', 'schluessel-ziel'], [$settings['turnstileSecret'], $settings['turnstileSiteKey']]); $zip = new \ZipArchive(); $zip->open($file); self::assertStringNotContainsString('geheim-quelle', (string) $zip->getFromName(SitePackage::DATA)); self::assertStringNotContainsString('hash-quelle', (string) $zip->getFromName(SitePackage::DATA), 'Ohne Häkchen keine Konten im Paket.'); $zip->close(); // Der Medienordner ist der des Pakets; der vorige Stand liegt in der Sicherung. self::assertSame([str_repeat('a', 32).'-480.webp'], array_values(array_diff(scandir($this->sites['ziel']['root'].'/var/media'), ['.', '..']))); self::assertNotEmpty(glob($this->sites['ziel']['root'].'/var/backups/sicherung-*'), 'Vor dem Import wird gesichert.'); self::assertFileDoesNotExist($this->sites['ziel']['root'].'/var/maintenance'); } public function testAccountsComeAlongOnlyWhenChosenOnBothSides(): void { // Paket ohne Konten, Häkchen gesetzt: abgelehnt, nichts geändert. $ohne = $this->sites['quelle']['package']->export(); $result = $this->sites['ziel']['package']->import($ohne, true); self::assertFalse($result['ok']); self::assertSame(['alte-seite'], $this->sites['ziel']['db']->fetchFirstColumn('SELECT path FROM page')); // Paket mit Konten, aber ohne Häkchen beim Import: die Konten bleiben. $mit = $this->sites['quelle']['package']->export(true); self::assertTrue($this->sites['ziel']['package']->import($mit)['ok']); self::assertSame(['ziel'], $this->sites['ziel']['db']->fetchFirstColumn('SELECT username FROM cms_user')); // Mit Häkchen: die Konten des Pakets. self::assertTrue($this->sites['ziel']['package']->import($mit, true)['ok']); self::assertSame([['username' => 'quelle', 'password_hash' => 'hash-quelle']], $this->sites['ziel']['db']->fetchAllAssociative('SELECT username, password_hash FROM cms_user')); } public function testAPackageFromANewerVersionIsRefused(): void { file_put_contents($this->sites['quelle']['root'].'/VERSION', "9.0.0\n"); $result = $this->sites['ziel']['package']->import($this->sites['quelle']['package']->export()); self::assertFalse($result['ok']); self::assertStringContainsString('Erst diese Website aktualisieren', $result['steps'][0]['detail']); self::assertSame(['alte-seite'], $this->sites['ziel']['db']->fetchFirstColumn('SELECT path FROM page')); } /** @return iterable */ public static function tamperedPackages(): iterable { yield 'fremde Datei' => [static fn (\ZipArchive $zip) => $zip->addFromString('../../public/hallo.php', ' [static fn (\ZipArchive $zip) => $zip->addFromString('medien/shell.php', ' [static function (\ZipArchive $zip): void { $zip->addFromString(SitePackage::DATA, str_replace('Seite der quelle', 'Untergeschoben', (string) $zip->getFromName(SitePackage::DATA))); }, 'Prüfsumme']; yield 'fremde Tabelle' => [static function (\ZipArchive $zip): void { $data = json_decode((string) $zip->getFromName(SitePackage::DATA), true); $data['tabellen']['form_submission'] = []; $raw = json_encode($data); $manifest = json_decode((string) $zip->getFromName(SitePackage::MANIFEST), true); $manifest['sha256'] = hash('sha256', $raw); $zip->addFromString(SitePackage::DATA, $raw); $zip->addFromString(SitePackage::MANIFEST, json_encode($manifest)); }, 'gehört nicht in ein Inhaltspaket']; yield 'ungültiges Dokument' => [static function (\ZipArchive $zip): void { $data = json_decode((string) $zip->getFromName(SitePackage::DATA), true); $data['tabellen']['revision'][0]['document'] = json_encode(['schemaVersion' => 1, 'title' => 'x', 'blocks' => [['id' => 'a', 'type' => 'unbekannt']]]); $raw = json_encode($data); $manifest = json_decode((string) $zip->getFromName(SitePackage::MANIFEST), true); $manifest['sha256'] = hash('sha256', $raw); $zip->addFromString(SitePackage::DATA, $raw); $zip->addFromString(SitePackage::MANIFEST, json_encode($manifest)); }, '']; } #[\PHPUnit\Framework\Attributes\DataProvider('tamperedPackages')] public function testATamperedPackageChangesNothing(callable $tamper, string $message): void { $file = $this->sites['quelle']['package']->export(); $zip = new \ZipArchive(); $zip->open($file); $tamper($zip); $zip->close(); $result = $this->sites['ziel']['package']->import($file); self::assertFalse($result['ok']); self::assertStringContainsString($message, json_encode($result['steps'], JSON_UNESCAPED_UNICODE)); self::assertSame(['alte-seite'], $this->sites['ziel']['db']->fetchFirstColumn('SELECT path FROM page')); self::assertSame([str_repeat('b', 32).'-480.webp'], array_values(array_diff(scandir($this->sites['ziel']['root'].'/var/media'), ['.', '..']))); self::assertFileDoesNotExist($this->sites['ziel']['root'].'/public/hallo.php'); } private static function remove(string $path): void { if (!is_dir($path)) { return; } foreach (scandir($path) ?: [] as $entry) { if ('.' !== $entry && '..' !== $entry) { is_dir($path.'/'.$entry) ? self::remove($path.'/'.$entry) : @unlink($path.'/'.$entry); } } @rmdir($path); } }