System now exports a content package and imports one, by upload or from var/pakete. Pages with their revisions, areas, menus, media, forms, redirects, terms, translations, design and settings are replaced; submissions, statistics, tasks, plugin state and service keys stay, and accounts only come along when that is chosen explicitly. A backup comes first, the site is in maintenance meanwhile, and every file and document in the package is checked before anything is written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
178 lines
10 KiB
PHP
178 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Tests;
|
|
|
|
use App\Content\DemoContent;
|
|
use App\Content\DocumentValidator;
|
|
use App\Content\FormRepository;
|
|
use App\Content\MediaLibrary;
|
|
use App\Content\PageRepository;
|
|
use App\Content\SiteSettings;
|
|
use App\Infrastructure\ConnectionFactory;
|
|
use App\Ops\Backup;
|
|
use App\Ops\CommandRunner;
|
|
use App\Ops\SitePackage;
|
|
use App\Ops\Updater;
|
|
use Doctrine\DBAL\Connection;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* Inhaltspakete zwischen zwei Installationen (ADR 118).
|
|
*
|
|
* Der Anlass war das Passwort, das sich nach jedem Hochladen einer Datenbank änderte. Deshalb steht
|
|
* der Test vorn, der das festhält: Inhalte kommen, Konten, Anfragen und Zugangsdaten zu Diensten
|
|
* bleiben - außer man nimmt die Konten ausdrücklich mit.
|
|
*/
|
|
final class SitePackageTest extends TestCase
|
|
{
|
|
/** @var array<string, array{root: string, db: Connection, package: SitePackage}> */
|
|
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<string, array{callable(\ZipArchive): void, string}> */
|
|
public static function tamperedPackages(): iterable
|
|
{
|
|
yield 'fremde Datei' => [static fn (\ZipArchive $zip) => $zip->addFromString('../../public/hallo.php', '<?php'), 'gehört nicht in ein Inhaltspaket'];
|
|
yield 'Skript als Medium' => [static fn (\ZipArchive $zip) => $zip->addFromString('medien/shell.php', '<?php'), 'gehört nicht in ein Inhaltspaket'];
|
|
yield 'veränderter Inhalt' => [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);
|
|
}
|
|
}
|