Fassung 1.7.0: verlässliche Installation, übersichtliche Verwaltung und bedienbare Bewegung
This commit is contained in:
1 parent
b164c4edfe
commit
495b26896e
55 files changed
+1632
-423
No files matched your search
+63
-36
@@ -85,6 +85,43 @@ final class CmsTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testEditorPaginationAndChoicesKeepTypesFiltersAndParentLookup(): void
|
||||
{
|
||||
$ids = [];
|
||||
for ($i = 0; $i < 43; ++$i) {
|
||||
$document = DemoContent::pages()['home'];
|
||||
$document['title'] = 'Eintrag '.sprintf('%02d', $i);
|
||||
$ids[] = $this->pages->createPage('eintrag-'.sprintf('%02d', $i), null, 'post', $document);
|
||||
}
|
||||
$first = $this->pages->editorPage('post');
|
||||
$second = $this->pages->editorPage('post', 2);
|
||||
self::assertSame(43, $first['total']);
|
||||
self::assertCount(20, $first['items']);
|
||||
self::assertCount(20, $second['items']);
|
||||
self::assertSame([], array_intersect(array_column($first['items'], 'id'), array_column($second['items'], 'id')));
|
||||
self::assertCount(3, $this->pages->editorPage('post', 999)['items']);
|
||||
self::assertSame(3, $this->pages->editorPage('post', 999)['page']);
|
||||
self::assertCount(43, $this->pages->editorPage('post', -1, 50)['items']);
|
||||
self::assertSame(2, $this->pages->editorPage('page')['total']);
|
||||
self::assertSame(1, $this->pages->editorPage('post', query: 'Eintrag 42')['total']);
|
||||
self::assertSame(0, $this->pages->editorPage('post', query: '%')['total'], 'LIKE wildcards are literal search text.');
|
||||
self::assertSame(0, $this->pages->editorPage('post', query: '_')['total']);
|
||||
$saved = $this->pages->findForEditor($ids[0]);
|
||||
$this->pages->publish($ids[0], $saved['draft_revision_id'], $saved['version']);
|
||||
self::assertSame(1, $this->pages->editorPage('post', status: 'published')['total']);
|
||||
self::assertSame(42, $this->pages->editorPage('post', status: 'draft')['total']);
|
||||
$this->pages->saveDraft($ids[0], DemoContent::pages()['home'], $saved['version'] + 1);
|
||||
self::assertSame(1, $this->pages->editorPage('post', status: 'changed')['total']);
|
||||
$this->db->update('page', ['deleted_at' => gmdate('Y-m-d H:i:s')], ['id' => $ids[42]]);
|
||||
self::assertSame(42, $this->pages->editorPage('post')['total']);
|
||||
self::assertSame(1, $this->pages->editorPage('post', status: 'trash')['total']);
|
||||
self::assertCount(20, $this->pages->choices(type: 'post'));
|
||||
self::assertSame([], $this->pages->choices(id: $ids[42]));
|
||||
self::assertCount(2, $this->pages->choices(parentsOnly: true));
|
||||
self::assertSame('page-2', $this->pages->choices(id: 'page-2', parentsOnly: true)[0]['id']);
|
||||
self::assertSame([], $this->pages->choices(id: $ids[1], parentsOnly: true));
|
||||
}
|
||||
|
||||
public function testDocumentRoundTripAndDraftPrivacy(): void
|
||||
{
|
||||
$document = DemoContent::pages()['home'];
|
||||
@@ -690,11 +727,8 @@ final class CmsTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testASliderAdvancesByItselfOnlyWhenItWasAskedTo(): void
|
||||
public function testSliderControlsLoadOnlyForPagesWithSliders(): void
|
||||
{
|
||||
// Autorotation braucht ein Skript - das einzige auf einer öffentlichen Seite außer den
|
||||
// Tabs. Es kommt nur auf Seiten, die einen solchen Slider tatsächlich enthalten, und die
|
||||
// Richtlinie bleibt sonst bei script-src 'none'.
|
||||
$document = DemoContent::starter('Mit Slider');
|
||||
$document['schemaVersion'] = 17;
|
||||
$document['blocks'][] = ['id' => 'slider', 'type' => 'slider', 'props' => [
|
||||
@@ -703,22 +737,26 @@ final class CmsTest extends TestCase
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
|
||||
$ohne = $this->request('/');
|
||||
self::assertStringNotContainsString('slider.js', $ohne->getContent());
|
||||
self::assertStringContainsString("script-src 'none'", (string) $ohne->headers->get('Content-Security-Policy'));
|
||||
self::assertStringContainsString('slider.js?v=', $ohne->getContent());
|
||||
self::assertStringNotContainsString('data-autoplay=', $ohne->getContent());
|
||||
self::assertStringContainsString("script-src 'self'", (string) $ohne->headers->get('Content-Security-Policy'));
|
||||
|
||||
$document['blocks'][array_key_last($document['blocks'])]['props']['autoplay'] = 7;
|
||||
$revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']);
|
||||
$this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']);
|
||||
|
||||
$mit = $this->request('/');
|
||||
self::assertStringContainsString('<script src="/assets/slider.js?v=', $mit->getContent());
|
||||
self::assertStringContainsString('<script type="module" src="/assets/slider.js?v=', $mit->getContent());
|
||||
self::assertStringContainsString('data-autoplay="7"', $mit->getContent());
|
||||
self::assertStringContainsString("script-src 'self'", (string) $mit->headers->get('Content-Security-Policy'));
|
||||
|
||||
// Das Skript hält sich an die Bewegungsschranke und hört beim ersten Eingriff auf.
|
||||
$skript = (string) file_get_contents(__DIR__.'/../public/assets/slider.js');
|
||||
self::assertStringContainsString("matchMedia('(prefers-reduced-motion: reduce)')", $skript);
|
||||
self::assertStringContainsString("'pointerdown', 'keydown', 'touchstart', 'wheel'", $skript);
|
||||
// Removing the block removes the public dependency and closes the script policy again.
|
||||
array_pop($document['blocks']);
|
||||
$revision = $this->pages->saveDraft('page-1', $document, $this->pages->findForEditor('page-1')['version']);
|
||||
$this->pages->publish('page-1', $revision, $this->pages->findForEditor('page-1')['version']);
|
||||
$plain = $this->request('/');
|
||||
self::assertStringNotContainsString('slider.js', $plain->getContent());
|
||||
self::assertStringContainsString("script-src 'none'", (string) $plain->headers->get('Content-Security-Policy'));
|
||||
}
|
||||
|
||||
public function testTheSamePostsCanBeShownInThreeWays(): void
|
||||
@@ -770,30 +808,19 @@ final class CmsTest extends TestCase
|
||||
(new DocumentValidator())->validate($document);
|
||||
}
|
||||
|
||||
public function testEntranceEffectsRunOnScrollWhereTheBrowserCanAndOnLoadOtherwise(): void
|
||||
public function testEntranceEffectsLoadOnlyWhenUsedAndKeepTheirSelectionInHtml(): void
|
||||
{
|
||||
// Umut: „Effekte gibt es keine“. Es gab sie - aber nur beim Zeigen, nicht beim Laden, und
|
||||
// eine Einblendung beim Laden ist unterhalb der Falz wertlos: sie ist vorbei, bevor
|
||||
// jemand hinsieht. Mit einer Scroll-Zeitachse läuft dieselbe Animation, wenn der
|
||||
// Abschnitt in den Blick kommt - ohne eine Zeile JavaScript.
|
||||
$css = (string) file_get_contents(__DIR__.'/../public/assets/enhancements.css');
|
||||
self::assertStringContainsString('@supports (animation-timeline: view())', $css);
|
||||
self::assertStringContainsString('animation-timeline: view()', $css);
|
||||
|
||||
// Beides steht hinter der Bewegungsschranke, und der Rückfall ist die Fassung von vorher:
|
||||
// ein Browser ohne Scroll-Zeitachse zeigt alles, nur eben beim Laden animiert.
|
||||
$geschuetzt = explode('@media (prefers-reduced-motion: no-preference) {', $css);
|
||||
self::assertStringNotContainsString('animation-timeline', $geschuetzt[0], 'Keine Bewegung außerhalb der Schranke.');
|
||||
self::assertSame(1, substr_count($css, '@supports (animation-timeline: view())'));
|
||||
|
||||
// Der wichtigste Teil, und er ist mit Messen bezahlt: auf dem Scrollweg wird **keine
|
||||
// Deckkraft** animiert. Eine Scroll-Zeitachse ist umkehrbar, also stünde alles unterhalb
|
||||
// der Falz dauerhaft am Anfang der Kurve - bei Deckkraft 0,85 wird aus #3d4d5c ein
|
||||
// #78838d, und damit 3,86:1 statt 7,1:1. axe hat genau das gemeldet.
|
||||
self::assertStringContainsString('@keyframes kant-scroll-rise { from { transform: translateY(20px) } to { transform: none } }', $css);
|
||||
self::assertStringContainsString('@keyframes kant-scroll-fade { from { transform: translateY(10px) } to { transform: none } }', $css);
|
||||
$scrollteil = explode('@supports (animation-timeline: view())', $css)[1];
|
||||
self::assertStringNotContainsString('opacity', explode('.fx-delay-long', $scrollteil)[0], 'Auf dem Scrollweg bleibt der Text voll deckend.');
|
||||
$document = DemoContent::starter('Mit Bewegung');
|
||||
$document['schemaVersion'] = 19;
|
||||
$document['blocks'][0]['layout']['effects']['entrance'] = 'left';
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
|
||||
$response = $this->request('/');
|
||||
self::assertStringContainsString('fx-entrance-left', $response->getContent());
|
||||
self::assertStringContainsString('motion.js?v=', $response->getContent());
|
||||
self::assertStringContainsString("script-src 'self'", (string) $response->headers->get('Content-Security-Policy'));
|
||||
unset($document['blocks'][0]['layout']['effects']['entrance']);
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 2), 3);
|
||||
self::assertStringNotContainsString('motion.js?v=', $this->request('/')->getContent());
|
||||
|
||||
// Und die drei Beitragsvorlagen bringen die Einblendung von sich aus mit.
|
||||
foreach (array_keys(PostLayouts::all()) as $key) {
|
||||
@@ -3307,8 +3334,8 @@ final class CmsTest extends TestCase
|
||||
foreach ($expected as $needle) {
|
||||
self::assertStringContainsString($needle, $html, $type);
|
||||
}
|
||||
// No script is needed for any of them: the public page still forbids scripting.
|
||||
self::assertStringContainsString("script-src 'none'", (string) $this->request('/')->headers->get('Content-Security-Policy'));
|
||||
// Sliders receive controls; the other blocks keep a script-free public page.
|
||||
self::assertStringContainsString('slider' === $type ? "script-src 'self'" : "script-src 'none'", (string) $this->request('/')->headers->get('Content-Security-Policy'));
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string, array<string, mixed>}> */
|
||||
|
||||
@@ -82,6 +82,34 @@ final class EditorSecurityTest extends WebTestCase
|
||||
return $client;
|
||||
}
|
||||
|
||||
public function testEverySharedAreaOpensPreviewsAndSavesWithConflictProtection(): void
|
||||
{
|
||||
$client = $this->login();
|
||||
$partials = new PartialRepository($this->db, new DocumentValidator());
|
||||
$partials->create('section', 'shared-test', 'Gemeinsamer Abschnitt', DemoContent::areas()['footer']['document']);
|
||||
foreach ($partials->all() as $partial) {
|
||||
$base = '/admin/partials/'.$partial['id'];
|
||||
$client->request('GET', $base);
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSelectorExists('#editor[data-api="'.$base.'"]');
|
||||
self::assertSelectorNotExists('#publish');
|
||||
$client->request('GET', $base.'/document');
|
||||
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||
$document = $data['document'];
|
||||
$document['title'] .= ' bearbeitet';
|
||||
$headers = ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $data['csrfToken']];
|
||||
$body = json_encode(['document' => $document, 'expectedVersion' => $data['version']], JSON_THROW_ON_ERROR);
|
||||
$client->request('POST', $base.'/preview', server: $headers, content: $body);
|
||||
self::assertResponseIsSuccessful();
|
||||
$client->request('POST', $base.'/draft', server: $headers, content: $body);
|
||||
self::assertResponseIsSuccessful();
|
||||
$client->request('POST', $base.'/draft', server: $headers, content: $body);
|
||||
self::assertResponseStatusCodeSame(409);
|
||||
$client->request('GET', $base.'/document');
|
||||
self::assertSame($document['title'], json_decode($client->getResponse()->getContent(), true)['document']['title']);
|
||||
}
|
||||
}
|
||||
|
||||
private function data(KernelBrowser $client): array
|
||||
{
|
||||
$client->request('GET', '/admin/api/pages/p1');
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Ops\Environment;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Dotenv\Dotenv;
|
||||
|
||||
final class EnvironmentTest extends TestCase
|
||||
{
|
||||
private string $root;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->root = sys_get_temp_dir().'/kant-environment-'.bin2hex(random_bytes(6));
|
||||
mkdir($this->root);
|
||||
file_put_contents($this->root.'/.env.example', "APP_ENV=prod\nDATABASE_URL=sqlite:///default.db\nMAILER_DSN=null://null\n");
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->root, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST);
|
||||
foreach ($files as $file) {
|
||||
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
|
||||
}
|
||||
rmdir($this->root);
|
||||
}
|
||||
|
||||
public function testNewInstallationUsesOnlyEnvAndKeepsSecretAcrossRequests(): void
|
||||
{
|
||||
Environment::prepare($this->root);
|
||||
$secret = Environment::ensureSecret($this->root);
|
||||
Environment::write($this->root, ['DATABASE_URL' => 'sqlite:///customer.db']);
|
||||
Environment::prepare($this->root);
|
||||
self::assertSame($secret, Environment::ensureSecret($this->root));
|
||||
self::assertSame('sqlite:///customer.db', $this->values()['DATABASE_URL']);
|
||||
self::assertFileDoesNotExist($this->root.'/.env.local');
|
||||
}
|
||||
|
||||
public function testLegacySettingsAreConsolidatedOnceWithBothOriginalsBackedUp(): void
|
||||
{
|
||||
Environment::prepare($this->root);
|
||||
$legacy = "DATABASE_URL=sqlite:///live.db\nAPP_SECRET=existing-secret\nCUSTOM_OPTION=kept\n";
|
||||
file_put_contents($this->root.'/.env.local', $legacy);
|
||||
Environment::prepare($this->root);
|
||||
self::assertSame('sqlite:///live.db', $this->values()['DATABASE_URL']);
|
||||
self::assertSame('existing-secret', Environment::ensureSecret($this->root));
|
||||
self::assertSame('kept', $this->values()['CUSTOM_OPTION']);
|
||||
self::assertFileDoesNotExist($this->root.'/.env.local');
|
||||
$backups = glob($this->root.'/var/backups/environment-*/.env.local');
|
||||
self::assertCount(1, $backups);
|
||||
self::assertSame($legacy, file_get_contents($backups[0]));
|
||||
Environment::write($this->root, ['DATABASE_URL' => 'sqlite:///chosen.db']);
|
||||
Environment::prepare($this->root);
|
||||
self::assertSame('sqlite:///chosen.db', $this->values()['DATABASE_URL'], 'No legacy override after editing .env.');
|
||||
self::assertCount(1, glob($this->root.'/var/backups/environment-*'));
|
||||
}
|
||||
|
||||
public function testSecretsAndCustomSettingsRoundTripWithoutInterpolation(): void
|
||||
{
|
||||
Environment::prepare($this->root);
|
||||
$value = 'a$NOT_AN_ENV_VALUE"b\\c';
|
||||
Environment::write($this->root, ['APP_SECRET' => $value, 'CUSTOM_OPTION' => "one\ntwo"]);
|
||||
Environment::write($this->root, ['SITE_ORIGIN' => 'https://example.org']);
|
||||
self::assertSame($value, $this->values()['APP_SECRET']);
|
||||
self::assertSame("one\ntwo", $this->values()['CUSTOM_OPTION']);
|
||||
self::assertSame('null://null', $this->values()['MAILER_DSN']);
|
||||
}
|
||||
|
||||
public function testFailedMigrationPreservesBothOriginalFiles(): void
|
||||
{
|
||||
Environment::prepare($this->root);
|
||||
$before = file_get_contents($this->root.'/.env');
|
||||
$legacy = "APP_SECRET=preserved\nDATABASE_URL=sqlite:///live.db\n";
|
||||
file_put_contents($this->root.'/.env.local', $legacy);
|
||||
unlink($this->root.'/.env.lock');
|
||||
mkdir($this->root.'/.env.lock'); // An unwritable lock target, reproducible on Windows/Linux.
|
||||
try {
|
||||
Environment::prepare($this->root);
|
||||
self::fail('Migration must report failure.');
|
||||
} catch (\RuntimeException $error) {
|
||||
self::assertStringContainsString('Schreibrechte', $error->getMessage());
|
||||
}
|
||||
self::assertSame($before, file_get_contents($this->root.'/.env'));
|
||||
self::assertSame($legacy, file_get_contents($this->root.'/.env.local'));
|
||||
}
|
||||
|
||||
public function testBootstrapIgnoresOtherOverlaysButHonorsExplicitServerVariables(): void
|
||||
{
|
||||
mkdir($this->root.'/config');
|
||||
mkdir($this->root.'/vendor');
|
||||
copy(dirname(__DIR__).'/config/bootstrap.php', $this->root.'/config/bootstrap.php');
|
||||
file_put_contents($this->root.'/vendor/autoload.php', '<?php require '.var_export(dirname(__DIR__).'/vendor/autoload.php', true).';');
|
||||
file_put_contents($this->root.'/.env', "APP_ENV=prod\nAPP_DEBUG=0\nAPP_SECRET=fixture-secret\nDATABASE_URL=sqlite:///chosen.db\n");
|
||||
file_put_contents($this->root.'/.env.prod', "DATABASE_URL=sqlite:///wrong.db\n");
|
||||
file_put_contents($this->root.'/.env.prod.local', "DATABASE_URL=sqlite:///also-wrong.db\n");
|
||||
file_put_contents($this->root.'/.env.local.php', '<?php return ["DATABASE_URL" => "sqlite:///cached-wrong.db"];');
|
||||
file_put_contents($this->root.'/boot.php', '<?php require __DIR__."/config/bootstrap.php"; echo $_SERVER["DATABASE_URL"];');
|
||||
foreach ([null, 'sqlite:///server.db'] as $override) {
|
||||
$environment = getenv();
|
||||
unset($environment['DATABASE_URL']);
|
||||
$environment['APP_ENV'] = 'prod';
|
||||
if (null !== $override) {
|
||||
$environment['DATABASE_URL'] = $override;
|
||||
}
|
||||
$process = proc_open([PHP_BINARY, $this->root.'/boot.php'], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $this->root, $environment);
|
||||
self::assertIsResource($process);
|
||||
$output = stream_get_contents($pipes[1]);
|
||||
$error = stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
self::assertSame(0, proc_close($process), $error);
|
||||
self::assertSame($override ?? 'sqlite:///chosen.db', $output);
|
||||
self::assertFileDoesNotExist($this->root.'/.env.local');
|
||||
}
|
||||
}
|
||||
|
||||
private function values(): array
|
||||
{
|
||||
return (new Dotenv())->parse((string) file_get_contents($this->root.'/.env'));
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ final class InstallFlowTest extends TestCase
|
||||
$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');
|
||||
$environment = (string) file_get_contents($this->root.'/.env');
|
||||
self::assertStringContainsString('DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"', $environment);
|
||||
self::assertStringContainsString('APP_ENV="prod"', $environment);
|
||||
|
||||
@@ -115,7 +115,7 @@ final class InstallFlowTest extends TestCase
|
||||
// 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');
|
||||
self::assertFileDoesNotExist($this->root.'/.env');
|
||||
}
|
||||
|
||||
/** Posts a form with the signature the rendered page carries, the way a browser would. */
|
||||
|
||||
@@ -89,7 +89,7 @@ final class InstallationTest extends TestCase
|
||||
$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.');
|
||||
self::assertFalse(is_file($this->root.'/.env'), 'Eine gescheiterte Prüfung schreibt nichts.');
|
||||
}
|
||||
|
||||
public function testTheEnvironmentFileKeepsWhatIsAlreadyThere(): void
|
||||
@@ -99,7 +99,7 @@ final class InstallationTest extends TestCase
|
||||
|
||||
// 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');
|
||||
$content = (string) file_get_contents($this->root.'/.env');
|
||||
self::assertStringContainsString('APP_SECRET="geheim"', $content);
|
||||
self::assertStringContainsString('DATABASE_URL="sqlite:///x.db"', $content);
|
||||
self::assertStringContainsString('SITE_ORIGIN="https://example.de"', $content);
|
||||
@@ -220,7 +220,7 @@ final class InstallationTest extends TestCase
|
||||
|
||||
private function settingsOrigin(): string
|
||||
{
|
||||
preg_match('/SITE_ORIGIN="([^"]*)"/', (string) file_get_contents($this->root.'/.env.local'), $match);
|
||||
preg_match('/SITE_ORIGIN="([^"]*)"/', (string) file_get_contents($this->root.'/.env'), $match);
|
||||
|
||||
return $match[1] ?? '';
|
||||
}
|
||||
|
||||
@@ -104,6 +104,26 @@ final class SystemScreenTest extends WebTestCase
|
||||
self::assertStringContainsString('Sicherung', $html);
|
||||
}
|
||||
|
||||
public function testTheScreenNamesTheDatabaseItActuallyUses(): void
|
||||
{
|
||||
// Umut hat `var/cms.db` gelöscht, weil in `.env` eine andere Datei stand - und die
|
||||
// Website war weg. Der Grund: `.env.local` übersteuert `.env`, und der Installer schreibt
|
||||
// nur `.env.local`. Wer nicht weiß, dass es diese Datei gibt, liest die falsche und
|
||||
// handelt danach. Eine Verwaltung, die das verschweigt, lädt genau dazu ein.
|
||||
$client = $this->login();
|
||||
$client->request('GET', '/admin/system');
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
|
||||
self::assertStringContainsString('SQLite', $html);
|
||||
self::assertStringContainsString(
|
||||
htmlspecialchars(str_replace('\\', '/', $this->path), ENT_QUOTES),
|
||||
$html,
|
||||
'Die Datei, die wirklich benutzt wird, muss dastehen - nicht die, die irgendwo konfiguriert ist.',
|
||||
);
|
||||
self::assertStringContainsString('Server-Umgebung', $html, 'Der Test übergibt die Verbindung ausdrücklich als Umgebungsvariable.');
|
||||
self::assertStringNotContainsString('übersteuert <code>.env</code>', $html);
|
||||
}
|
||||
|
||||
public function testTheScreenShowsWhatTheArchiveUpdaterDidInsteadOfDyingOnIt(): void
|
||||
{
|
||||
// Der Fehler, den Umut auf seinem Hosting gesehen hat: Das Update lief, legte seine
|
||||
|
||||
+32
-2
@@ -115,6 +115,22 @@ final class UpdaterTest extends TestCase
|
||||
self::assertStringContainsString('OK v1.1.0', $log[0]);
|
||||
}
|
||||
|
||||
public function testTrackedEnvironmentFromAnOlderTagCannotReplaceInstallationSettings(): void
|
||||
{
|
||||
file_put_contents($this->author.'/.env', "DATABASE_URL=sqlite:///release-default.db\n");
|
||||
$this->release('1.1.0', true);
|
||||
$this->git(['fetch', '--tags', 'origin'], $this->work);
|
||||
$this->git(['checkout', 'v1.1.0'], $this->work);
|
||||
$settings = "DATABASE_URL=sqlite:///customer.db\nAPP_SECRET=customer-secret\n";
|
||||
file_put_contents($this->work.'/.env', $settings);
|
||||
$this->git(['update-index', '--assume-unchanged', '.env'], $this->work);
|
||||
unlink($this->author.'/.env');
|
||||
$this->release('1.2.0', true);
|
||||
$result = $this->updater()->update();
|
||||
self::assertTrue($result['ok'], $this->explain($result));
|
||||
self::assertSame($settings, file_get_contents($this->work.'/.env'));
|
||||
}
|
||||
|
||||
public function testAFailingSmokeTestRollsBackTheCodeAndTheDatabase(): void
|
||||
{
|
||||
// The new version ships a console that fails - as a broken release would.
|
||||
@@ -126,6 +142,7 @@ final class UpdaterTest extends TestCase
|
||||
$steps = array_column($result['steps'], 'step');
|
||||
self::assertContains('Zurück auf den alten Stand', $steps);
|
||||
self::assertContains('Datenbank zurückschreiben', $steps);
|
||||
self::assertTrue($this->named($result['steps'], 'Datenbank zurückschreiben', 'step')['ok']);
|
||||
// The migration is where it broke, so the database had to go back too.
|
||||
self::assertSame('Migration', $this->firstFailed($result)['step']);
|
||||
self::assertSame('1.0.0', $updater->version(), 'A failed update leaves the previous version installed.');
|
||||
@@ -136,6 +153,16 @@ final class UpdaterTest extends TestCase
|
||||
self::assertStringContainsString('FEHLER v1.2.0', $updater->log()[0]);
|
||||
}
|
||||
|
||||
public function testAFailedDatabaseRestoreKeepsTheSiteInMaintenance(): void
|
||||
{
|
||||
$this->release('1.2.0', false, loseBackup: true);
|
||||
$result = $this->updater()->update();
|
||||
self::assertFalse($result['ok']);
|
||||
self::assertFalse($this->named($result['steps'], 'Datenbank zurückschreiben', 'step')['ok']);
|
||||
self::assertFileExists($this->work.'/'.Updater::MAINTENANCE_FILE);
|
||||
self::assertNotContains('Wieder online', array_column($result['steps'], 'step'));
|
||||
}
|
||||
|
||||
public function testAnUpdateThatChangesDependenciesStopsWhenComposerIsMissing(): void
|
||||
{
|
||||
// The installation carries the lock of 1.0.0; the new version brings a different one. On
|
||||
@@ -217,7 +244,7 @@ final class UpdaterTest extends TestCase
|
||||
* Publishes a release: VERSION, a .gitignore that keeps the installation out of Git, and the
|
||||
* two stub commands the updater calls. `$healthy` decides whether they succeed.
|
||||
*/
|
||||
private function release(string $version, bool $healthy, string $lock = 'alt'): void
|
||||
private function release(string $version, bool $healthy, string $lock = 'alt', bool $loseBackup = false): void
|
||||
{
|
||||
file_put_contents($this->author.'/composer.lock', json_encode(['packages' => [$lock]]));
|
||||
file_put_contents($this->author.'/VERSION', $version."\n");
|
||||
@@ -234,8 +261,11 @@ final class UpdaterTest extends TestCase
|
||||
// Stubs, not the real console: this test is about the updater's order and its rollback.
|
||||
$stub = $healthy
|
||||
? "<?php\nfwrite(STDOUT, 'OK '.implode(' ', array_slice(\$argv, 1)).PHP_EOL);\nexit(0);\n"
|
||||
: "<?php\nfwrite(STDOUT, 'Migration gescheitert'.PHP_EOL);\nexit(1);\n";
|
||||
: "<?php\nfile_put_contents(__DIR__.'/../../var/cms.db', 'Unvollständige Migration');\nfwrite(STDOUT, 'Migration gescheitert'.PHP_EOL);\nexit(1);\n";
|
||||
file_put_contents($this->author.'/bin/console', $healthy ? $stub : "<?php\nfwrite(STDOUT, 'OK'.PHP_EOL);\nexit(0);\n");
|
||||
if ($loseBackup) {
|
||||
$stub = str_replace('exit(1);', "foreach (glob(__DIR__.'/../../var/backups/update-*/cms.db') ?: [] as \$backup) { unlink(\$backup); } exit(1);", $stub);
|
||||
}
|
||||
file_put_contents($this->author.'/vendor/bin/doctrine-migrations', $stub);
|
||||
$this->commit($this->author, 'Fassung '.$version);
|
||||
$this->git(['tag', 'v'.$version], $this->author);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
// Read the local pilot only to make a consistent, isolated copy. Never write to its database.
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$source = $argv[1] ?? $root.'/var/kantit.db';
|
||||
if (!is_file($source)) {
|
||||
throw new RuntimeException('Die Pilotdatenbank fehlt. Pfad als erstes Argument angeben.');
|
||||
}
|
||||
$directory = $root.'/var/browser-tests/pilot-'.bin2hex(random_bytes(6));
|
||||
mkdir($directory, 0o700, true);
|
||||
$path = $directory.'/pilot.db';
|
||||
$original = new SQLite3($source, SQLITE3_OPEN_READONLY);
|
||||
$copy = new SQLite3($path);
|
||||
if (!$original->backup($copy)) {
|
||||
throw new RuntimeException('Die Pilotdatenbank konnte nicht kopiert werden.');
|
||||
}
|
||||
$copy->close();
|
||||
$original->close();
|
||||
$url = 'sqlite:///'.str_replace('\\', '/', $path);
|
||||
$db = ConnectionFactory::create($url);
|
||||
$db->executeStatement('UPDATE scheduled_task SET enabled = 0');
|
||||
$username = 'qa-'.bin2hex(random_bytes(6));
|
||||
$password = bin2hex(random_bytes(20));
|
||||
$db->insert('cms_user', ['id' => $username, 'username' => $username, 'password_hash' => password_hash($password, PASSWORD_BCRYPT, ['cost' => 4]), 'roles' => '["ROLE_ADMIN"]']);
|
||||
echo json_encode(['databaseUrl' => $url, 'mediaDirectory' => $root.'/var/kantit-media', 'directory' => $directory, 'username' => $username, 'password' => $password, 'homeId' => $db->fetchOne("SELECT id FROM page WHERE path = 'home'")], JSON_THROW_ON_ERROR);
|
||||
Reference in new issue
Block a user