Installer: sechs Bildschirme, und eine Datei davor
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

/install fuehrt von einer leeren Installation zu einer Website:
Systempruefung, Datenbank (SQLite oder MySQL, Verbindung sofort geprueft),
Migration im Prozess ohne Konsole, Website mit Startseite, Bereichen, Menue
sowie Impressum und Datenschutzgeruest, erstes Konto, Abschlussliste.

Jeder Schritt schreibt sein Ergebnis sofort, damit ein Neuladen nichts
verliert. Gesperrt wird ueber den Zustand der Datenbank - gibt es ein
Konto, antwortet /install mit 404. Eine Datei koennte jemand loeschen.

Solange nichts installiert ist, fuehrt jede Adresse zum Installer statt zu
einem 500 aus einer fehlenden Tabelle. Und weil ohne APP_SECRET nicht
einmal die Seite bootet, die das erklaeren wuerde, erzeugt der erste Start
das Geheimnis selbst.

scripts/install.php ist die einzelne Datei davor: Adresse und SHA-256
stehen darin (vom Buildskript eingetragen), ohne passende Pruefsumme wird
nicht entpackt, jeder Archiveintrag wird gegen das Zielverzeichnis
geprueft, und danach loescht sie sich selbst.

Geprueft: 379 Tests unter PHP 8.2 und 8.4, darunter der ganze Ablauf durch
die HTTP-Schicht, neun ZIP-Slip-Varianten, axe ohne Verstoss auf allen
Installationsbildschirmen, und das entpackte Releasearchiv startet in den
Installer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 23:11:50 +02:00
1 parent c3bff7d936
commit e34b4dca67
25 files changed
+1937 -7

No files matched your search

+219
View File
@@ -0,0 +1,219 @@
<?php
namespace App\Tests;
use App\Infrastructure\ConnectionFactory;
use App\Kernel;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* The six screens, through the actual HTTP layer, from an empty webspace to a working website.
*
* The kernel here has its own project directory, a copy with only the parts a kernel reads:
* configuration, templates, migrations. Everything the installer writes - the environment file,
* the database, the marker - lands there and nowhere near the installation I work in.
*
* The point of the test is the acceptance criterion of the package: six steps in the browser,
* without a console, and afterwards the installer is gone and the website answers.
*/
final class InstallFlowTest extends TestCase
{
private string $root;
private string $database;
protected function setUp(): void
{
$this->root = sys_get_temp_dir().'/kant-flow-'.bin2hex(random_bytes(6));
foreach (['config', 'templates', 'var', 'public'] as $directory) {
mkdir($this->root.'/'.$directory, 0o775, true);
}
// src/ is copied as well, because the service definitions scan that directory. The
// classes themselves still come from the autoloader, so this is only a list of files.
foreach (['config', 'templates', 'src'] as $directory) {
self::copyDirectory(\dirname(__DIR__).'/'.$directory, $this->root.'/'.$directory);
}
// The migrations are not copied but pointed at: a second copy of the same classes in one
// process is a fatal error, and a copied migration would age away from the original.
file_put_contents($this->root.'/migrations.php', sprintf(
"<?php return ['table_storage' => ['table_name' => 'migration_versions'], 'migrations_paths' => ['DoctrineMigrations' => %s], 'all_or_nothing' => false];",
var_export(\dirname(__DIR__).'/migrations', true),
));
$this->database = $this->root.'/var/cms.db';
}
protected function tearDown(): void
{
gc_collect_cycles();
self::removeDirectory($this->root);
}
public function testAnEmptyInstallationBecomesAWebsiteInSixSteps(): void
{
// 0. Whatever is asked for, the answer is the installer. A missing table is not something
// to explain with a stack trace.
$response = $this->request('/');
self::assertSame(302, $response->getStatusCode());
self::assertSame('/install', $response->headers->get('Location'));
// 1. The system check.
$check = $this->request('/install');
self::assertSame(200, $check->getStatusCode());
self::assertStringContainsString('Passt dieser Server?', $check->getContent());
// 2. The database. SQLite needs nothing but a writable var/.
$database = $this->request('/install/datenbank');
$response = $this->submit('/install/datenbank', $database, ['driver' => 'sqlite', 'name' => 'cms']);
self::assertSame('/install/migration', $response->headers->get('Location'));
$environment = (string) file_get_contents($this->root.'/.env.local');
self::assertStringContainsString('DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"', $environment);
self::assertStringContainsString('APP_ENV="prod"', $environment);
// 3. The migrations, in this process, without a console.
$migration = $this->request('/install/migration');
self::assertStringContainsString('Migration', $migration->getContent());
$response = $this->submit('/install/migration', $migration, []);
self::assertSame('/install/website', $response->headers->get('Location'));
self::assertNotEmpty($this->connection()->fetchAllAssociative('SELECT version FROM migration_versions'));
// 4. The website, and with it the content a site cannot go live without.
$website = $this->request('/install/website');
$response = $this->submit('/install/website', $website, ['name' => 'Beispiel GmbH', 'origin' => 'https://beispiel.de', 'locale' => 'de']);
self::assertSame('/install/konto', $response->headers->get('Location'));
self::assertSame(3, (int) $this->connection()->fetchOne('SELECT COUNT(*) FROM page'), 'Startseite, Impressum, Datenschutz.');
// 5. The first account - and the last screen, rendered straight away, because one request
// later this route no longer exists.
$account = $this->request('/install/konto');
$done = $this->submit('/install/konto', $account, ['username' => 'chefin', 'password' => 'ein sehr langes passwort', 'repeat' => 'ein sehr langes passwort']);
self::assertSame(200, $done->getStatusCode());
self::assertStringContainsString('Fertig.', $done->getContent());
self::assertStringContainsString('Impressum und Datenschutzerklärung ausfüllen', $done->getContent());
// 6. The installer is over, and the website answers.
self::assertSame(404, $this->request('/install')->getStatusCode());
self::assertSame(404, $this->request('/install/konto')->getStatusCode());
$home = $this->request('/');
self::assertSame(200, $home->getStatusCode());
self::assertStringContainsString('Beispiel GmbH', $home->getContent());
self::assertStringContainsString('Diese Seite ist der Anfang.', $home->getContent());
// The public page is still a public page: no script, and the header carries the name.
self::assertStringContainsString("script-src 'none'", (string) $home->headers->get('Content-Security-Policy'));
self::assertSame(200, $this->request('/impressum')->getStatusCode());
self::assertSame(200, $this->request('/admin/login')->getStatusCode());
}
public function testAStepWithoutItsRequirementSendsYouBack(): void
{
// Somebody who guesses the address of a later step does not get past the missing database.
self::assertSame('/install/datenbank', $this->request('/install/migration')->headers->get('Location'));
self::assertSame('/install/migration', $this->request('/install/website')->headers->get('Location'));
self::assertSame('/install/website', $this->request('/install/konto')->headers->get('Location'));
// And a form without a valid signature changes nothing: the page comes back, unchanged.
$response = $this->request('/install/datenbank', 'POST', ['driver' => 'sqlite', 'name' => 'cms', 'issued' => time(), 'signature' => 'erfunden']);
self::assertSame(200, $response->getStatusCode());
self::assertFileDoesNotExist($this->root.'/.env.local');
}
/** Posts a form with the signature the rendered page carries, the way a browser would. */
private function submit(string $url, Response $page, array $fields): Response
{
preg_match('/name="issued" value="(\d+)"/', (string) $page->getContent(), $issued);
preg_match('/name="signature" value="([a-f0-9]{64})"/', (string) $page->getContent(), $signature);
self::assertNotEmpty($issued, 'Das Formular trägt einen Zeitpunkt.');
self::assertNotEmpty($signature, 'Das Formular trägt eine Unterschrift.');
return $this->request($url, 'POST', [...$fields, 'issued' => $issued[1], 'signature' => $signature[1]]);
}
private function request(string $url, string $method = 'GET', array $body = []): Response
{
$previous = [];
foreach ([
'APP_SECRET' => 'test-only',
'DATABASE_URL' => 'sqlite:///'.str_replace('\\', '/', $this->database),
'SITE_ORIGIN' => 'https://beispiel.de',
'MEDIA_DIRECTORY' => $this->root.'/var/media',
] as $key => $value) {
$previous[$key] = [$_SERVER[$key] ?? null, $_ENV[$key] ?? null];
$_SERVER[$key] = $_ENV[$key] = $value;
}
$kernel = new InstallTestKernel($this->root);
try {
$request = Request::create($url, $method, $body);
$response = $kernel->handle($request);
$kernel->terminate($request, $response);
return $response;
} finally {
$kernel->shutdown();
foreach ($previous as $key => [$server, $environment]) {
unset($_SERVER[$key], $_ENV[$key]);
if (null !== $server) {
$_SERVER[$key] = $server;
}
if (null !== $environment) {
$_ENV[$key] = $environment;
}
}
}
}
private function connection(): Connection
{
return ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->database));
}
private static function copyDirectory(string $from, string $to): void
{
@mkdir($to, 0o775, true);
foreach (scandir($from) ?: [] as $entry) {
if ('.' === $entry || '..' === $entry) {
continue;
}
is_dir($from.'/'.$entry)
? self::copyDirectory($from.'/'.$entry, $to.'/'.$entry)
: copy($from.'/'.$entry, $to.'/'.$entry);
}
}
private static function removeDirectory(string $path): void
{
if (!is_dir($path)) {
return;
}
foreach (scandir($path) ?: [] as $entry) {
if ('.' !== $entry && '..' !== $entry) {
is_dir($path.'/'.$entry) ? self::removeDirectory($path.'/'.$entry) : @unlink($path.'/'.$entry);
}
}
@rmdir($path);
}
}
/** A kernel that lives in a throwaway directory, so the installer writes there and not here. */
final class InstallTestKernel extends Kernel
{
public function __construct(private string $projectDir)
{
parent::__construct('test', false);
}
public function getProjectDir(): string
{
return $this->projectDir;
}
public function getCacheDir(): string
{
return $this->projectDir.'/var/cache';
}
public function getLogDir(): string
{
return $this->projectDir.'/var/log';
}
}
+249
View File
@@ -0,0 +1,249 @@
<?php
namespace App\Tests;
use App\Content\DocumentValidator;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\SiteSettings;
use App\Domain\CmsUser;
use App\Domain\InvalidPage;
use App\Infrastructure\ConnectionFactory;
use App\Ops\Installation;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
/**
* The installer against an empty directory and an empty database.
*
* Not against the development installation: an installer writes an environment file, creates
* tables and puts content into them, and a test that does that where I work would be a way to
* lose an afternoon. Everything here happens in a throwaway directory with a throwaway database -
* and the migrations are the real ones, run the way the browser installer runs them, because that
* is the part that has to work on hosting without a console.
*/
final class InstallationTest extends TestCase
{
private string $root;
private Connection $db;
private Installation $installation;
protected function setUp(): void
{
$this->root = sys_get_temp_dir().'/kant-install-'.bin2hex(random_bytes(6));
mkdir($this->root.'/var', 0o775, true);
mkdir($this->root.'/public', 0o775, true);
// The same migration configuration a real installation has, pointing at the real
// migrations: a copy of the list would drift away from it within a week.
file_put_contents($this->root.'/migrations.php', sprintf(
"<?php return ['table_storage' => ['table_name' => 'migration_versions'], 'migrations_paths' => ['DoctrineMigrations' => %s], 'all_or_nothing' => false];",
var_export(\dirname(__DIR__).'/migrations', true),
));
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->root).'/var/cms.db');
$this->installation = $this->make();
}
protected function tearDown(): void
{
$this->db->close();
gc_collect_cycles();
$this->removeDirectory($this->root);
}
public function testAnEmptyInstallationIsNotInstalledAndSaysWhatIsMissing(): void
{
// No database, no tables, no account: the installer has to survive all three, because it
// is the only screen that exists at that moment.
self::assertFalse($this->installation->isInstalled());
self::assertFalse($this->installation->schemaReady());
self::assertFalse($this->installation->siteReady());
self::assertFalse($this->installation->environmentWritten());
$checks = $this->installation->checks();
$labels = array_column($checks, 'label');
self::assertContains('PHP-Fassung', $labels);
self::assertContains('Datenbanktreiber', $labels);
foreach ($checks as $check) {
// A failing check without a sentence about what to do is a dead end.
self::assertNotSame('', $check['hint'], $check['label'].' braucht einen Hinweis.');
}
// The document root of a test process is not public/, so this check has to fail - and it
// has to fail without blocking, because a wrong document root is the server's business.
$documentRoot = self::findCheck($checks, 'Dokumentenstamm zeigt auf public/');
self::assertFalse($documentRoot['ok']);
self::assertFalse($documentRoot['required']);
}
public function testTheDatabaseStepProvesTheConnectionBeforeItWritesAnything(): void
{
$good = $this->installation->testDatabase('sqlite', ['name' => 'cms']);
self::assertNull($good['error']);
self::assertSame('sqlite:///%kernel.project_dir%/var/cms.db', $good['url']);
// A MySQL host that does not answer must fail here and not two screens later.
$bad = $this->installation->testDatabase('mysql', ['host' => '127.0.0.1', 'port' => '1', 'name' => 'x', 'user' => 'y', 'password' => 'z']);
self::assertNotNull($bad['error']);
self::assertStringNotContainsString('geheimespasswort', $this->installation->testDatabase('mysql', ['host' => '127.0.0.1', 'port' => '1', 'name' => 'x', 'user' => 'y', 'password' => 'geheimespasswort'])['error'], 'Das Passwort gehört in keine Fehlermeldung.');
self::assertFalse(is_file($this->root.'/.env.local'), 'Eine gescheiterte Prüfung schreibt nichts.');
}
public function testTheEnvironmentFileKeepsWhatIsAlreadyThere(): void
{
$this->installation->writeEnvironment(['APP_SECRET' => 'geheim', 'DATABASE_URL' => 'sqlite:///x.db']);
self::assertTrue($this->installation->environmentWritten());
// The website step adds the address later and must not lose the database doing it.
$this->installation->writeEnvironment(['SITE_ORIGIN' => 'https://example.de']);
$content = (string) file_get_contents($this->root.'/.env.local');
self::assertStringContainsString('APP_SECRET="geheim"', $content);
self::assertStringContainsString('DATABASE_URL="sqlite:///x.db"', $content);
self::assertStringContainsString('SITE_ORIGIN="https://example.de"', $content);
self::assertSame(1, substr_count($content, 'DATABASE_URL='), 'Kein Wert steht doppelt in der Datei.');
}
public function testTheWholeInstallationRunsThroughAndThenLocksItself(): void
{
$pending = $this->installation->pendingMigrations();
self::assertNotEmpty($pending, 'Eine leere Datenbank hat ausstehende Migrationen.');
$applied = $this->installation->migrate();
self::assertSame(\count($pending), \count($applied));
self::assertTrue($this->installation->schemaReady());
// Running again is allowed and does nothing: a browser that timed out is not a problem.
self::assertSame([], $this->installation->migrate());
$created = $this->installation->createSite('Beispiel GmbH', 'https://beispiel.de/', 'de');
self::assertTrue($this->installation->siteReady());
self::assertContains('Startseite', $created);
self::assertContains('Impressum', $created);
self::assertContains('Datenschutzerklärung', $created);
// The start page is published, not a draft: a fresh installation that answers 404 at its
// own address would look broken.
$home = $this->make()->isInstalled();
self::assertFalse($home, 'Ohne Konto gilt die Installation als unfertig.');
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db));
self::assertNotNull($pages->findPublished('home'));
self::assertNotNull($pages->findPublished('impressum'));
self::assertSame('https://beispiel.de', $this->settingsOrigin());
// The legal pages are linked, otherwise the consent gate and the footer stay empty.
$settings = (new SiteSettings($this->db))->load();
self::assertSame('datenschutz', $settings['privacyPath']);
self::assertSame('impressum', $settings['imprintPath']);
self::assertSame('Beispiel GmbH', $settings['legalName']);
// Running the website step twice creates nothing twice.
self::assertSame([], $this->installation->createSite('Beispiel GmbH', 'https://beispiel.de', 'de'));
$this->installation->createAdmin('chefin', 'ein sehr langes passwort', 'ein sehr langes passwort');
self::assertTrue($this->installation->isInstalled(), 'Mit dem ersten Konto ist die Installation vorbei.');
$row = $this->db->fetchAssociative('SELECT username, password_hash, roles FROM cms_user');
self::assertSame('chefin', $row['username']);
self::assertStringNotContainsString('passwort', $row['password_hash'], 'Das Passwort steht nirgends im Klartext.');
self::assertSame(['ROLE_ADMIN'], json_decode($row['roles'], true));
self::assertTrue(
(new UserPasswordHasher(new PasswordHasherFactory([CmsUser::class => ['algorithm' => 'auto']])))
->isPasswordValid(new CmsUser('x', 'chefin', $row['password_hash'], ['ROLE_ADMIN']), 'ein sehr langes passwort'),
);
// And the second account is refused, because that is what "the installer is over" means.
$this->expectException(InvalidPage::class);
$this->installation->createAdmin('zweite', 'noch ein langes passwort', 'noch ein langes passwort');
}
#[DataProvider('badAccounts')]
public function testAnAccountWithWeakInputIsRefused(string $username, string $password, string $repeat): void
{
$this->installation->migrate();
$this->expectException(InvalidPage::class);
$this->installation->createAdmin($username, $password, $repeat);
}
public static function badAccounts(): iterable
{
yield 'zu kurzer Name' => ['ab', 'ein sehr langes passwort', 'ein sehr langes passwort'];
yield 'Leerzeichen im Namen' => ['die chefin', 'ein sehr langes passwort', 'ein sehr langes passwort'];
yield 'zu kurzes Passwort' => ['chefin', 'kurz', 'kurz'];
yield 'Tippfehler bei der Wiederholung' => ['chefin', 'ein sehr langes passwort', 'ein sehr langes passwortt'];
}
#[DataProvider('badSites')]
public function testAWebsiteWithBadInputIsRefused(string $name, string $origin, string $locale): void
{
$this->installation->migrate();
$this->expectException(InvalidPage::class);
$this->installation->createSite($name, $origin, $locale);
}
public static function badSites(): iterable
{
yield 'ohne Namen' => ['', 'https://beispiel.de', 'de'];
yield 'Adresse ohne Schema' => ['Beispiel', 'beispiel.de', 'de'];
yield 'keine Adresse' => ['Beispiel', 'https://', 'de'];
yield 'unbekannte Sprache' => ['Beispiel', 'https://beispiel.de', 'kl'];
}
public function testAFormFromAnotherStepOrAnotherDayDoesNotCount(): void
{
$issued = time();
$signature = $this->installation->sign('website', $issued);
self::assertTrue($this->installation->verify('website', $issued, $signature));
self::assertFalse($this->installation->verify('konto', $issued, $signature), 'Eine Unterschrift gilt für einen Schritt.');
self::assertFalse($this->installation->verify('website', $issued - Installation::TOKEN_LIFETIME - 60, $signature));
self::assertFalse($this->installation->verify('website', $issued, 'falsch'));
}
private function make(): Installation
{
$settings = new SiteSettings($this->db);
$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',
'ein-test-geheimnis',
);
}
private function settingsOrigin(): string
{
preg_match('/SITE_ORIGIN="([^"]*)"/', (string) file_get_contents($this->root.'/.env.local'), $match);
return $match[1] ?? '';
}
private static function findCheck(array $checks, string $label): array
{
foreach ($checks as $check) {
if ($check['label'] === $label) {
return $check;
}
}
self::fail('Prüfung fehlt: '.$label);
}
private function removeDirectory(string $path): void
{
if (!is_dir($path)) {
return;
}
foreach (scandir($path) ?: [] as $entry) {
if ('.' !== $entry && '..' !== $entry) {
is_dir($path.'/'.$entry) ? $this->removeDirectory($path.'/'.$entry) : @unlink($path.'/'.$entry);
}
}
@rmdir($path);
}
}
+140
View File
@@ -0,0 +1,140 @@
<?php
namespace App\Tests;
use PHPUnit\Framework\TestCase;
/**
* The one file that is uploaded by FTP before anything else exists.
*
* It runs outside the application - no autoloader, no container, no framework - so it is tested
* the way it runs: included, then called. Nothing here downloads anything; what is tested is the
* part that decides whether something lands on the server, and where.
*/
final class SingleFileInstallerTest extends TestCase
{
private string $target;
public static function setUpBeforeClass(): void
{
// In a CLI process the file returns before it renders anything, so including it only
// defines its functions.
require_once \dirname(__DIR__).'/scripts/install.php';
}
protected function setUp(): void
{
$this->target = sys_get_temp_dir().'/kant-zip-'.bin2hex(random_bytes(6));
mkdir($this->target, 0o775, true);
}
protected function tearDown(): void
{
foreach (glob($this->target.'/*') ?: [] as $entry) {
is_dir($entry) ? self::removeDirectory($entry) : @unlink($entry);
}
@rmdir($this->target);
}
public function testTheVersionFolderIsStrippedAndTheTargetIsRespected(): void
{
$target = str_replace('\\', '/', $this->target);
self::assertSame($target.'/public/index.php', kant_target_path('kantsites-1.2.0/public/index.php', $this->target));
// Without the version folder the entry is taken as it is.
self::assertSame($target.'/bin/console', kant_target_path('bin/console', $this->target));
}
/**
* @param string $entry the archive entry a manipulated archive could contain
*/
#[\PHPUnit\Framework\Attributes\DataProvider('dangerousEntries')]
public function testNothingLandsOutsideTheTargetDirectory(string $entry): void
{
self::assertNull(kant_target_path($entry, $this->target), $entry.' darf nirgends landen.');
}
public static function dangerousEntries(): iterable
{
yield 'zwei Ebenen nach oben' => ['kantsites-1.2.0/../../.env'];
yield 'direkt nach oben' => ['../evil.php'];
yield 'mitten im Pfad' => ['kantsites-1.2.0/src/../../../evil.php'];
yield 'absolut' => ['/etc/passwd'];
yield 'Windows-Laufwerk' => ['C:/Windows/evil.php'];
yield 'Rückwärtsschrägstrich' => ['kantsites-1.2.0\\..\\..\\evil.php'];
yield 'Nullbyte' => ["kantsites-1.2.0/src/evil\0.php"];
yield 'nur das Verzeichnis' => ['kantsites-1.2.0/'];
yield 'leer' => [''];
}
public function testAnOrdinaryArchiveIsUnpackedAndAManipulatedOneIsNot(): void
{
$good = $this->archive(['kantsites-1.2.0/public/index.php' => '<?php echo 1;', 'kantsites-1.2.0/src/Kernel.php' => 'x']);
self::assertSame(2, kant_unpack($good, $this->target));
self::assertSame('<?php echo 1;', file_get_contents($this->target.'/public/index.php'));
self::assertFileExists($this->target.'/src/Kernel.php');
// An archive with an entry that points outside is refused - and the refusal happens while
// unpacking, so what matters is that the file outside never appears.
$evil = $this->archive(['kantsites-1.2.0/harmlos.txt' => 'ok', 'kantsites-1.2.0/../entwischt.txt' => 'böse']);
try {
kant_unpack($evil, $this->target);
self::fail('Ein Eintrag außerhalb des Ziels muss zum Abbruch führen.');
} catch (\RuntimeException $e) {
self::assertStringContainsString('außerhalb', $e->getMessage());
}
self::assertFileDoesNotExist(\dirname($this->target).'/entwischt.txt');
}
public function testThePreflightNamesWhatIsMissing(): void
{
$checks = kant_checks($this->target);
$labels = array_column($checks, 'label');
self::assertContains('ZIP-Erweiterung', $labels);
self::assertContains('Schreibrecht im Zielverzeichnis', $labels);
// This copy comes from the repository, not from a release: no address, no checksum, and
// it says so instead of downloading something.
$unfinished = self::find($checks, 'Adresse und Prüfsumme eingetragen');
self::assertFalse($unfinished['ok']);
// An existing installation is not overwritten, not even partially.
mkdir($this->target.'/public', 0o775, true);
file_put_contents($this->target.'/public/index.php', 'x');
self::assertFalse(self::find(kant_checks($this->target), 'Verzeichnis ist leer genug')['ok']);
}
private function archive(array $entries): string
{
$path = $this->target.'/../kant-archive-'.bin2hex(random_bytes(4)).'.zip';
$zip = new \ZipArchive();
$zip->open($path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
foreach ($entries as $name => $content) {
$zip->addFromString($name, $content);
}
$zip->close();
self::assertFileExists($path);
register_shutdown_function(static fn () => @unlink($path));
return $path;
}
private static function find(array $checks, string $label): array
{
foreach ($checks as $check) {
if ($check['label'] === $label) {
return $check;
}
}
self::fail('Prüfung fehlt: '.$label);
}
private static function removeDirectory(string $path): void
{
foreach (scandir($path) ?: [] as $entry) {
if ('.' !== $entry && '..' !== $entry) {
is_dir($path.'/'.$entry) ? self::removeDirectory($path.'/'.$entry) : @unlink($path.'/'.$entry);
}
}
@rmdir($path);
}
}