Files
KantSites/tests/ArchiveUpdateTest.php
kantarciandClaude Opus 5.5 723680b7de Add the central site registry and remote updates
Every installation now reports address and version to sites.kant-it.de
every 15 minutes and picks up update orders there (ADR 123). Reports and
answers are HMAC-signed with a per-installation key sent on first
contact; an order can only install a published release with checksum.
The next request after the file swap runs the migrations, so a remote
update finishes without anyone in the admin.

The platform itself lives in zentrale/ (plain PHP + SQLite, not part of
the release archive): overview with outdated, silent and unassigned
installations, single and bulk updates, per-site detail and history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:43:13 +02:00

567 lines
29 KiB
PHP

<?php
namespace App\Tests;
use App\Content\DocumentValidator;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\SiteSettings;
use App\EventSubscriber\RemoteUpdateFinishSubscriber;
use App\Infrastructure\ConnectionFactory;
use App\Ops\ArchiveUpdate;
use App\Ops\Backup;
use App\Ops\CommandRunner;
use App\Ops\Download;
use App\Ops\Funktionspruefung;
use App\Ops\Installation;
use App\Ops\Tasks\ZentraleTask;
use App\Ops\Updater;
use App\Ops\Zentrale;
use App\Security\CmsUser;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
/**
* Das Update über das Releasearchiv - der Weg für Installationen ohne Git.
*
* Auf geteiltem Hosting gibt es keine Arbeitskopie, also auch kein `git fetch`. Dort wird über
* das Archiv installiert und über das Archiv aktualisiert, und das ist der Weg, den die meisten
* Installationen dieses CMS nehmen werden. Er war bis hierhin ungeprüft.
*
* Geprüft wird gegen eine Installation in einem Wegwerfverzeichnis und eine Releaseseite, die im
* Speicher liegt (@see ReleaseOnDisk): echte Archive, echte Prüfsummen, echtes Entpacken, echte
* Umbenennungen - nur eben ohne Netz. Die Frage, um die es geht, ist nicht „läuft der Download",
* sondern: Bleibt stehen, was dem Betreiber gehört?
*/
final class ArchiveUpdateTest extends TestCase
{
private const BASE = 'https://releases.example.invalid/download';
private const API = 'https://releases.example.invalid/api';
private string $root;
private Connection $db;
private ReleaseOnDisk $releases;
protected function setUp(): void
{
$this->root = str_replace('\\', '/', sys_get_temp_dir()).'/kant-archiv-'.bin2hex(random_bytes(6));
// Eine Installation, wie sie nach dem Entpacken eines Releases aussieht.
foreach (['bin', 'config', 'migrations', 'src', 'templates', 'vendor', 'var/media', 'public/assets/fonts', 'public/assets/build'] as $verzeichnis) {
mkdir($this->root.'/'.$verzeichnis, 0o775, true);
}
$this->write('VERSION', "1.0.0\n");
$this->write('vendor/autoload.php', "<?php // alt\n");
$this->write('src/Alt.php', "<?php // wird ersetzt\n");
$this->write('composer.json', '{"name":"alt"}');
$this->write('composer.lock', '{"packages":[]}');
$this->write('migrations.php', "<?php return [];\n");
$this->write('cli-config.php', "<?php // alt\n");
$this->write('public/index.php', "<?php // alt\n");
$this->write('public/assets/site.css', 'body{color:alt}');
// Was dem Betreiber gehört - hochgeladene Schriften, das erzeugte Bündel, seine Medien,
// seine Datenbank, sein Geheimnis. Nichts davon steht im Archiv.
$this->write('public/assets/fonts/eigene.woff2', 'SCHRIFT');
$this->write('public/assets/build/bundle.css', 'body{color:erzeugt}');
$this->write('var/media/bild.jpg', 'BILD');
$this->write('.env', "APP_SECRET=geheim\n");
$this->db = ConnectionFactory::create('sqlite:///'.$this->root.'/var/cms.db');
TestSchema::build($this->db);
$this->releases = new ReleaseOnDisk();
}
protected function tearDown(): void
{
$this->db->close();
gc_collect_cycles();
$this->removeDirectory($this->root);
}
public function testAnUpdateReplacesTheApplicationAndLeavesTheInstallationUntouched(): void
{
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
$this->write('var/cache/prod/running-service.php', '<?php // Still needed by this request.');
$ergebnis = $this->updater()->run();
self::assertTrue($ergebnis['ok'], $this->explain($ergebnis));
self::assertFileExists($this->root.'/var/cache/prod/running-service.php', 'The update request still loads compiled services after replacing files.');
self::assertTrue($ergebnis['migrate'] ?? false, 'Nach dem Ersetzen fehlen noch die Migrationen.');
self::assertSame(
['Fassung ermitteln', 'Sicherung', 'Archiv laden', 'Prüfsumme', 'Entpacken', 'Wartungszustand', 'Dateien ersetzen', 'Zwischenspeicher', 'Dateien sind ersetzt'],
array_column($ergebnis['steps'], 'step'),
'Die Reihenfolge ist der Punkt: gesichert wird vor dem ersten Handgriff, geprüft vor dem Entpacken.',
);
// Die Anwendung ist die neue.
self::assertSame('1.1.0', trim((string) file_get_contents($this->root.'/VERSION')));
self::assertFileExists($this->root.'/src/Neu.php');
self::assertFileDoesNotExist($this->root.'/src/Alt.php', 'src/ wird ersetzt, nicht ergänzt.');
self::assertStringContainsString('neu', (string) file_get_contents($this->root.'/public/index.php'));
self::assertSame('body{color:neu}', file_get_contents($this->root.'/public/assets/site.css'));
// Und alles, was dem Betreiber gehört, steht noch da.
self::assertSame('SCHRIFT', file_get_contents($this->root.'/public/assets/fonts/eigene.woff2'));
self::assertFileDoesNotExist($this->root.'/public/assets/fonts/andere.woff2', 'Schriften aus dem Archiv würden die hochgeladenen überschreiben.');
self::assertSame('body{color:erzeugt}', file_get_contents($this->root.'/public/assets/build/bundle.css'));
self::assertSame('BILD', file_get_contents($this->root.'/var/media/bild.jpg'));
self::assertStringContainsString('APP_SECRET=geheim', (string) file_get_contents($this->root.'/.env'));
self::assertFileExists($this->root.'/var/cms.db');
// Die Website bleibt bis zu den Migrationen im Wartungszustand, und der alte Stand liegt
// noch da, falls doch etwas zurückgeschoben werden muss.
self::assertFileExists($this->root.'/'.Updater::MAINTENANCE_FILE);
self::assertCount(1, glob($this->root.'/var/backups/sicherung-*') ?: []);
self::assertCount(1, glob($this->root.'/var/update/*/alt/VERSION') ?: []);
// Der zweite Schritt ist auf der Platte vermerkt, nicht in der Sitzung: fällt sie aus,
// bliebe die Website sonst im Wartungszustand und der Knopf wäre weg.
$offen = $this->updater()->pending();
self::assertNotNull($offen, 'Ohne Marke findet niemand mehr den zweiten Schritt.');
self::assertSame('1.1.0', $offen['fassung']);
// Mit dem Einmalschlüssel ruft die Zwischenseite den Abschluss selbst auf (ADR 112).
self::assertMatchesRegularExpression('/^[a-f0-9]{32}$/D', (string) ($offen['token'] ?? ''));
self::assertSame($offen['token'], $ergebnis['token']);
self::assertSame('1.1.0', $ergebnis['fassung']);
}
public function testNothingIsPendingBeforeAnUpdateAndAfterAFailedOne(): void
{
self::assertNull($this->updater()->pending());
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'), str_repeat('a', 64));
$this->updater()->run();
self::assertNull($this->updater()->pending(), 'Ein Update, das nicht ersetzt hat, lässt nichts offen.');
}
public function testAnArchiveWithTheWrongChecksumIsNeverUnpacked(): void
{
// Der Fall, für den die Prüfsumme da ist: unterwegs ausgetauscht oder halb übertragen.
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'), str_repeat('a', 64));
$ergebnis = $this->updater()->run();
self::assertFalse($ergebnis['ok']);
self::assertSame('Prüfsumme', $this->firstFailed($ergebnis)['step']);
self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION')), 'Nichts wurde ersetzt.');
self::assertFileExists($this->root.'/src/Alt.php');
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE, 'Die Website darf davon nichts merken.');
}
public function testAnIncompleteArchiveIsRefusedBeforeTheSiteGoesIntoMaintenance(): void
{
// Ein Archiv ohne vendor/ ist auf einem Hosting ohne Composer wertlos - das muss auffallen,
// bevor die halbe Anwendung ersetzt ist.
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0', false));
$ergebnis = $this->updater()->run();
self::assertFalse($ergebnis['ok']);
self::assertSame('Entpacken', $this->firstFailed($ergebnis)['step']);
self::assertStringContainsString('vendor', $this->firstFailed($ergebnis)['detail']);
self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION')));
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE);
}
public function testAnUnreachableReleasePageChangesNothingAtAll(): void
{
// Viele Hoster lassen ausgehende Verbindungen nicht zu. Dann passiert hier gar nichts -
// insbesondere wird nicht gesichert, nicht ersetzt und kein Arbeitsordner angelegt.
$ergebnis = $this->updater()->run();
self::assertFalse($ergebnis['ok']);
self::assertCount(1, $ergebnis['steps']);
self::assertStringContainsString('Releaseseite', $ergebnis['steps'][0]['detail']);
self::assertSame([], glob($this->root.'/var/update/*') ?: []);
self::assertSame([], glob($this->root.'/var/backups/*') ?: []);
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE);
}
public function testTheNewestVersionAlreadyInstalledIsNotAFailure(): void
{
$this->releases->release(self::BASE, self::API, '1.0.0', $this->archive('1.0.0'));
$updater = $this->updater();
self::assertNull($updater->updateAvailable());
$ergebnis = $updater->run();
self::assertTrue($ergebnis['ok'], $this->explain($ergebnis));
self::assertFalse($ergebnis['migrate'] ?? false);
self::assertSame('Nichts zu tun', end($ergebnis['steps'])['step']);
self::assertSame([], glob($this->root.'/var/backups/*') ?: []);
}
public function testTheAvailableVersionIsOnlyReportedWhenItIsNewer(): void
{
self::assertNull($this->updater()->updateAvailable(), 'Ohne Antwort gibt es keine neue Fassung.');
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
self::assertSame('1.1.0', $this->updater()->updateAvailable());
// Etwas, das keine Fassung ist, wird nicht angeboten - sonst lädt der Updater irgendetwas.
$this->releases->serve(self::API.'/releases/latest', json_encode(['tag_name' => 'nightly']));
self::assertNull($this->updater()->updateAvailable());
}
public function testTheDownloaderRefusesEverythingThatIsNotHttps(): void
{
// Kein Netz nötig, um das zu prüfen: die Sperre greift vor dem ersten Byte. Ein Update
// über eine unverschlüsselte Verbindung könnte unterwegs ausgetauscht worden sein.
$download = new Download();
foreach (['http://releases.example.invalid/a.zip', 'ftp://releases.example.invalid/a.zip', 'file:///etc/passwd', '/tmp/a.zip'] as $url) {
self::assertNull($download->get($url), $url.' darf nicht geholt werden.');
self::assertStringContainsString('HTTPS', $download->reason(), 'Und es muss dastehen, woran es lag.');
}
}
public function testAFailedDownloadSaysWhatWentWrong(): void
{
// Drei Ursachen sehen in der Verwaltung sonst gleich aus: kein Weg nach draußen, kein
// Zertifikatsspeicher, falsche Adresse. Die mittlere ist auf einem Webhosting die
// häufigste und in einer Minute behoben - wenn jemand sie liest.
$download = new Download(connectTimeout: 2, timeout: 5);
self::assertNull($download->get('https://kein-server.invalid/datei.zip'));
self::assertNotSame('', $download->reason(), 'Ein Fehlschlag ohne Grund ist eine Sackgasse.');
// Und der Weg durch den Updater: Die Meldung im Schritt trägt den Grund mit.
$this->releases->serve(self::API.'/releases/latest', json_encode(['tag_name' => 'v1.1.0']));
$this->releases->serve(sprintf('%s/v1.1.0/kantsites-1.1.0.zip.sha256', self::BASE), str_repeat('a', 64));
$ergebnis = $this->updater()->run();
self::assertFalse($ergebnis['ok']);
$gescheitert = $this->firstFailed($ergebnis);
self::assertSame('Archiv laden', $gescheitert['step']);
self::assertStringContainsString('herunterladen', $gescheitert['detail']);
}
public function testEveryStepIsWrittenToTheLogWhileItHappens(): void
{
// Auf Umuts Hosting endete die Ergebnisseite mit 500, und damit war auch verloren, was
// der Updater berichtet hatte. Eine Zeile in einer Datei überlebt das - und auf einem
// Hosting ohne Konsole ist sie manchmal das Einzige, was von einem gescheiterten Update
// übrig bleibt. Geschrieben wird während des Laufs, nicht am Ende: Ein Lauf, der
// mittendrin abbricht, hat sonst nichts hinterlassen.
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'), str_repeat('a', 64));
$this->updater()->run();
$protokoll = (string) file_get_contents($this->root.'/var/updates.log');
self::assertStringContainsString('Update aus dem Archiv', $protokoll);
self::assertStringContainsString('ok Fassung ermitteln', $protokoll);
self::assertStringContainsString('FEHLER Prüfsumme', $protokoll, 'Gerade der gescheiterte Schritt muss dort stehen.');
}
public function testTheUpdaterReplacesEveryEntryTheReleaseArchiveShips(): void
{
// Der Fehler, der schon einmal auf dem Hosting aufgeschlagen ist, andersherum: Kommt eine
// Datei neu ins Release, ohne dass sie hier in der Ersetzungsliste steht, bleibt sie beim
// Update auf dem alten Stand - und das fällt erst auf, wenn etwas nicht mehr passt.
$skript = (string) file_get_contents(__DIR__.'/../scripts/build-release.php');
preg_match('/\$include = \[(.+?)\];/s', $skript, $mit);
self::assertNotEmpty($mit, 'Die Mitnahmeliste des Releaseskripts ist nicht auffindbar.');
$quelle = (string) file_get_contents(__DIR__.'/../src/Ops/ArchiveUpdate.php');
preg_match('/ERSETZEN = \[(.+?)\];/s', $quelle, $ersetzt);
self::assertNotEmpty($ersetzt, 'Die Ersetzungsliste des Updaters ist nicht auffindbar.');
$liste = static fn (string $roh): array => array_filter(array_map(
static fn (string $eintrag): string => trim($eintrag, " '\r\n\t"),
explode(',', $roh),
));
// public/ wird Datei für Datei zusammengeführt statt ersetzt, die drei anderen sind
// Beiwerk, das eine laufende Installation nicht liest.
// plugins/ ebenso Datei für Datei: mitgelieferte Plugins werden aktualisiert, ein vom
// Betreiber installiertes bleibt stehen (ADR 102).
// Die .htaccess im Projektstamm ersetzt das Update nicht: sie kann dem Betreiber gehören.
// Fehlt sie, legt die Website sie selbst an (App\Ops\Protection, ADR 116).
$anders = ['public', 'plugins', 'README.md', 'LICENSE', '.env.example', '.htaccess'];
foreach ($liste($mit[1]) as $eintrag) {
self::assertContains(
$eintrag,
[...$liste($ersetzt[1]), ...$anders],
sprintf('„%s" liegt im Releasearchiv, wird beim Update aber weder ersetzt noch bewusst ausgelassen.', $eintrag),
);
}
}
public function testTheProgressFileFollowsTheUpdateAndAFinishedOneSaysSo(): void
{
// Die Systemseite fragt diesen Fortschritt ab, während eine andere Anfrage das Update
// ausführt (ADR 113) - Phase, Meldung, von welcher auf welche Fassung.
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
$updater = $this->updater();
self::assertNull($updater->status());
self::assertSame('Fortschritt live.', $updater->latest()['notes'] ?? null, 'Die Hinweise der Releaseseite gehören zur neuen Fassung.');
$ergebnis = $updater->run();
self::assertTrue($ergebnis['ok'], $this->explain($ergebnis));
$status = $updater->status();
self::assertSame(['migrieren', true, '1.0.0', '1.1.0'], [$status['phase'], $status['laeuft'], $status['von'], $status['auf']]);
$fertig = $this->updater()->finish($this->installation());
self::assertTrue($fertig['ok'], $this->explain($fertig));
self::assertSame(['fertig', false], [$this->updater()->status()['phase'], $this->updater()->status()['laeuft']]);
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE);
}
public function testANewVersionThatDoesNotRunIsSwitchedBackCompletely(): void
{
// Wie bei reiseSoft: Läuft die neue Fassung nach den Migrationen nicht, kommt die alte
// zurück - Code, public/ und Datenbank -, und erst dann geht die Wartungsseite aus.
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
$this->db->insert('website', ['id' => 'default', 'name' => 'Vorher', 'default_locale' => 'de', 'theme' => '{}']);
$ergebnis = $this->updater()->run();
self::assertTrue($ergebnis['ok'], $this->explain($ergebnis));
self::assertSame('1.1.0', trim((string) file_get_contents($this->root.'/VERSION')));
// Etwas, das die neue Fassung in der Datenbank anrichtet, bevor die Prüfung scheitert.
$this->db->update('website', ['name' => 'Nachher'], ['id' => 'default']);
$ende = $this->updater()->finish($this->installation(), static fn (): string => 'Die Startseite antwortet mit 500.');
self::assertFalse($ende['ok']);
self::assertTrue($ende['zurueckgeschaltet'] ?? false, $this->explain($ende));
self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION')));
self::assertFileExists($this->root.'/src/Alt.php');
self::assertFileDoesNotExist($this->root.'/src/Neu.php');
self::assertSame("<?php // alt\n", file_get_contents($this->root.'/public/index.php'), 'Auch public/ kommt zurück.');
self::assertSame('body{color:alt}', file_get_contents($this->root.'/public/assets/site.css'));
self::assertSame('SCHRIFT', file_get_contents($this->root.'/public/assets/fonts/eigene.woff2'));
self::assertFileDoesNotExist($this->root.'/public/assets/fonts/andere.woff2');
$this->db->close();
$this->db = ConnectionFactory::create('sqlite:///'.$this->root.'/var/cms.db');
self::assertSame('Vorher', $this->db->fetchOne("SELECT name FROM website WHERE id = 'default'"), 'Die Datenbank kommt aus der Sicherung vor dem Update.');
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE, 'Zurückgeschaltet heißt: die Website läuft wieder.');
self::assertNull($this->updater()->pending());
$status = $this->updater()->status();
self::assertSame(['zurueckgerollt', 'pruefen', false], [$status['phase'], $status['bei'], $status['laeuft']], 'Gescheitert ist die Prüfung - genau dieser Schritt wird markiert.');
self::assertStringContainsString('Zurückgeschaltet auf 1.0.0', $status['meldung']);
}
public function testARemoteOrderUpdatesTheSiteAndTheNextRequestFinishesIt(): void
{
// ADR 123: Die Zentrale merkt sich „Update auf 1.1.0“, die Website holt es mit ihrer
// Meldung ab, tauscht die Dateien, und die nächste Anfrage führt die Migrationen aus.
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
$server = $this->zentraleServer(['id' => 7, 'aktion' => 'update', 'fassung' => '1.1.0']);
$zentrale = $this->zentrale($server['senden']);
$task = new ZentraleTask($zentrale, $this->updater(), new Updater($this->root, 'sqlite:///'.$this->root.'/var/cms.db', new CommandRunner(), $this->db), 'https://kunde.example.de', $this->root, function (string $adresse): void {
self::assertSame('https://kunde.example.de/', $adresse);
$this->naechsteAnfrage();
});
$meldung = $task->run();
self::assertSame('1.1.0', trim((string) file_get_contents($this->root.'/VERSION')), $meldung);
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE, 'Die nächste Anfrage hat abgeschlossen und die Website freigegeben.');
self::assertNull($this->updater()->pending());
$meldungen = $server['meldungen']();
self::assertArrayHasKey('schluessel', $meldungen[0], 'Beim ersten Kontakt geht der Schlüssel mit.');
self::assertArrayNotHasKey('schluessel', $meldungen[1], 'Danach nur noch die Unterschrift.');
self::assertSame('1.0.0', $meldungen[0]['fassung']);
$letzte = end($meldungen);
self::assertSame('1.1.0', $letzte['fassung']);
self::assertSame(['id' => 7, 'ok' => true], array_intersect_key($letzte['auftrag'] ?? [], ['id' => 1, 'ok' => 1]), 'Das Ergebnis geht an die Zentrale zurück.');
self::assertNull($zentrale->offenerAuftrag(), 'Nach der Quittung ist der Auftrag erledigt.');
}
public function testAnAnswerWithoutAValidSignatureChangesNothing(): void
{
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
$zentrale = $this->zentrale(static fn (): array => [
'status' => 200,
'body' => '{"ok":true,"auftrag":{"id":1,"aktion":"update","fassung":"1.1.0"}}',
'signatur' => str_repeat('0', 64),
]);
try {
$zentrale->melden();
self::fail('Eine Antwort ohne gültige Unterschrift wurde angenommen.');
} catch (\RuntimeException $fehler) {
self::assertStringContainsString('Unterschrift', $fehler->getMessage());
}
self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION')));
}
public function testALocalInstallationDoesNotReport(): void
{
$gesendet = false;
$senden = static function () use (&$gesendet): array {
$gesendet = true;
return ['status' => 500, 'body' => '', 'signatur' => ''];
};
foreach (['http://127.0.0.1:8106', 'http://localhost', 'https://kunde.test'] as $adresse) {
$zentrale = new Zentrale($this->root, new Updater($this->root, 'sqlite:///x', new CommandRunner(), $this->db), $this->updater(), 'https://sites.example.invalid', $adresse, 'sqlite:///x', $senden);
self::assertFalse($zentrale->aktiv(), $adresse);
}
self::assertFalse($this->zentrale(null, 'aus')->aktiv(), 'ZENTRALE_URL=aus schaltet ab.');
self::assertFalse($gesendet);
}
/** Die Anfrage nach dem Tausch: startet mit dem neuen Code und schließt ab. */
private function naechsteAnfrage(): void
{
$pruefung = $this->createStub(Funktionspruefung::class);
$pruefung->method('__invoke')->willReturn(null);
$runner = new CommandRunner();
$subscriber = new RemoteUpdateFinishSubscriber($this->updater(), $this->installation(), new Updater($this->root, 'sqlite:///'.$this->root.'/var/cms.db', $runner, $this->db), $pruefung, $this->root);
$subscriber->onRequest(new RequestEvent($this->createStub(HttpKernelInterface::class), Request::create('/'), HttpKernelInterface::MAIN_REQUEST));
}
private function zentrale(?\Closure $senden, string $url = 'https://sites.example.invalid'): Zentrale
{
$url_db = 'sqlite:///'.$this->root.'/var/cms.db';
return new Zentrale($this->root, new Updater($this->root, $url_db, new CommandRunner(), $this->db), $this->updater(), $url, 'https://kunde.example.de', $url_db, $senden);
}
/**
* Eine Zentrale im Speicher: lernt den Schlüssel beim ersten Kontakt, prüft danach jede
* Unterschrift, gibt den Auftrag einmal heraus und unterschreibt jede Antwort.
*
* @return array{senden: \Closure, meldungen: \Closure}
*/
private function zentraleServer(?array $auftrag): array
{
$schluessel = null;
$meldungen = [];
$senden = function (string $url, string $body, array $kopf) use (&$schluessel, &$meldungen, &$auftrag): array {
$daten = json_decode($body, true);
$meldungen[] = $daten;
$schluessel ??= $daten['schluessel'] ?? null;
self::assertIsString($schluessel);
self::assertTrue(hash_equals(hash_hmac('sha256', $body, $schluessel), $kopf['X-KantSites-Signatur']), 'Jede Meldung ist unterschrieben.');
$antwort = json_encode(['ok' => true, 'auftrag' => $auftrag]);
$auftrag = null;
return ['status' => 200, 'body' => $antwort, 'signatur' => hash_hmac('sha256', $antwort, $schluessel)];
};
return ['senden' => $senden, 'meldungen' => static function () use (&$meldungen): array {
return $meldungen;
}];
}
private function installation(): Installation
{
$settings = new SiteSettings($this->db, new MediaLibrary($this->db, $this->root.'/var/media'));
$validator = new DocumentValidator();
return new Installation(
$this->db,
new PageRepository($this->db, $validator, $settings),
new PartialRepository($this->db, $validator),
new MenuRepository($this->db, $settings),
$settings,
new UserPasswordHasher(new PasswordHasherFactory([CmsUser::class => ['algorithm' => 'auto']])),
$this->root,
$this->root.'/var/media',
'test-secret',
);
}
private function updater(): ArchiveUpdate
{
$runner = new CommandRunner();
$url = 'sqlite:///'.$this->root.'/var/cms.db';
return new ArchiveUpdate(
$this->root,
new Updater($this->root, $url, $runner, $this->db),
new Backup($this->root, $url, $this->root.'/var/media', $this->db, $runner),
$this->releases,
self::BASE,
self::API,
);
}
/**
* Baut ein Releasearchiv, wie `scripts/build-release.php` es baut: ein Verzeichnis
* `kantsites-<Fassung>/` im Zip, darin die Anwendung.
*/
private function archive(string $version, bool $vollstaendig = true): string
{
$pfad = $this->root.'/var/release-'.$version.'.zip';
@mkdir(dirname($pfad), 0o775, true);
$zip = new \ZipArchive();
self::assertTrue($zip->open($pfad, \ZipArchive::CREATE | \ZipArchive::OVERWRITE));
$inhalt = [
'VERSION' => $version."\n",
'src/Neu.php' => "<?php // neu\n",
'bin/console' => "<?php // neu\n",
'config/services.yaml' => "services:\n",
'templates/base.html.twig' => 'neu',
'migrations.php' => "<?php return ['table_storage' => ['table_name' => 'migration_versions'], 'migrations_paths' => ['DoctrineMigrations' => __DIR__.'/migrations'], 'all_or_nothing' => false];\n",
'migrations/.keep' => '',
'cli-config.php' => "<?php // neu\n",
'composer.json' => '{"name":"neu"}',
'composer.lock' => '{"packages":["neu"]}',
'public/index.php' => "<?php // neu\n",
'public/assets/site.css' => 'body{color:neu}',
// Das Archiv bringt eigene Schriften und ein eigenes Bündel mit - beides darf die
// Installation nicht überschreiben.
'public/assets/fonts/andere.woff2' => 'FREMD',
'public/assets/build/bundle.css' => 'body{color:fremd}',
];
if ($vollstaendig) {
$inhalt['vendor/autoload.php'] = "<?php // neu\n";
}
foreach ($inhalt as $name => $text) {
$zip->addFromString('kantsites-'.$version.'/'.$name, $text);
}
$zip->close();
$roh = (string) file_get_contents($pfad);
unlink($pfad);
return $roh;
}
private function write(string $relativ, string $inhalt): void
{
@mkdir(dirname($this->root.'/'.$relativ), 0o775, true);
file_put_contents($this->root.'/'.$relativ, $inhalt);
}
/** @param array{steps: list<array{step: string, ok: bool, detail: string}>} $ergebnis */
private function firstFailed(array $ergebnis): array
{
foreach ($ergebnis['steps'] as $schritt) {
if (!$schritt['ok']) {
return $schritt;
}
}
self::fail('Kein gescheiterter Schritt: '.$this->explain($ergebnis));
}
private function explain(array $ergebnis): string
{
return implode("\n", array_map(
static fn (array $schritt): string => sprintf('%s %s: %s', $schritt['ok'] ? 'ok' : 'FEHLER', $schritt['step'], $schritt['detail']),
$ergebnis['steps'],
));
}
private function removeDirectory(string $pfad): void
{
if (!is_dir($pfad)) {
@unlink($pfad);
return;
}
foreach (scandir($pfad) ?: [] as $eintrag) {
if ('.' !== $eintrag && '..' !== $eintrag) {
@chmod($pfad.'/'.$eintrag, 0o777);
$this->removeDirectory($pfad.'/'.$eintrag);
}
}
@rmdir($pfad);
}
}