Files
KantSites/tests/SitePackageTest.php
T
kantarciandClaude Opus 5.5 32e42f973b Move site content between installations as a package
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>
2026-10-12 16:31:06 +02:00

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