$cookies
+ */
+function runInstallerRemoval(array $fixture, array $post, array $cookies = [], string $method = 'POST'): Process
+{
+ $processor = str_replace('\\', '/', dirname(__DIR__, 3))
+ . '/manager/processors/remove_installer.processor.php';
+ $runner = $fixture['root'] . '/request.php';
+ $source = "run();
+
+ return $process;
+}
+
+function removeInstallerWebFixture(array $fixture): void
+{
+ (new Filesystem())->deleteDirectory($fixture['root']);
+}
+
+test('fresh web install requires successful completion before its installer can be removed', function () {
+ $fixture = installerWebFixture();
+ $token = str_repeat('a', 64);
+
+ try {
+ file_put_contents(
+ $fixture['lock'],
+ " $token,
+ 'rminstaller' => '1',
+ ]);
+
+ expect($beforeCompletion->isSuccessful())->toBeTrue()
+ ->and($beforeCompletion->getOutput())->toContain('Not found.')
+ ->and(is_dir($fixture['install']))->toBeTrue();
+
+ expect(InstallerCompletion::writeLock(
+ $fixture['lock'],
+ 'anonymous-session',
+ '203.0.113.10',
+ time(),
+ $token
+ ))->toBeTrue();
+
+ $getAttempt = runInstallerRemoval($fixture, [
+ 'installer_token' => $token,
+ 'rminstaller' => '1',
+ ], [], 'GET');
+
+ expect($getAttempt->isSuccessful())->toBeTrue()
+ ->and($getAttempt->getOutput())->toContain('Not found.')
+ ->and(is_dir($fixture['install']))->toBeTrue();
+
+ $afterCompletion = runInstallerRemoval($fixture, [
+ 'installer_token' => $token,
+ 'rminstaller' => '1',
+ ]);
+
+ expect($afterCompletion->isSuccessful())->toBeTrue()
+ ->and($afterCompletion->getOutput())->toContain("window.location='../#?a=2'")
+ ->and(is_dir($fixture['install']))->toBeFalse()
+ ->and(is_file($fixture['lock']))->toBeFalse();
+ } finally {
+ removeInstallerWebFixture($fixture);
+ }
+});
diff --git a/core/tests/Feature/SiteUpdateE2ETest.php b/core/tests/Feature/SiteUpdateE2ETest.php
index c5d737ab30..af97b91cdf 100644
--- a/core/tests/Feature/SiteUpdateE2ETest.php
+++ b/core/tests/Feature/SiteUpdateE2ETest.php
@@ -22,7 +22,13 @@
use Illuminate\Database\Capsule\Manager as Capsule;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Facades\Facade;
+use EvolutionCMS\Models\SystemCliTask;
+use EvolutionCMS\Services\SystemTasks\SchedulerHealthService;
+use EvolutionCMS\Services\SystemTasks\SiteUpdateFlowService;
+use EvolutionCMS\Services\SystemTasks\SystemTaskService;
+use EvolutionCMS\Services\SystemTasks\WorkerHealthService;
if (!defined('EVO_BASE_PATH')) {
define('EVO_BASE_PATH', str_replace('\\', '/', dirname(__DIR__, 3)) . '/');
@@ -202,6 +208,38 @@ public function applyExtrasModule(): void
$command->applyExtrasModule();
}
+/**
+ * Build an isolated core tree whose Artisan entry point records the requested target as the
+ * installed version. SiteUpdateFlowService still launches it through the real process boundary.
+ */
+function managerUpgradeCoreFixture(string $currentVersion): string
+{
+ $core = str_replace('\\', '/', sys_get_temp_dir()) . '/evo-manager-upgrade-' . bin2hex(random_bytes(8)) . '/core';
+ mkdir($core . '/factory', 0777, true);
+ file_put_contents(
+ $core . '/factory/version.php',
+ " $currentVersion], true) . ";\n"
+ );
+ file_put_contents($core . '/artisan', <<<'PHP'
+ $targetVersion], true) . ";\n"
+);
+echo "Evolution CMS $targetVersion updated\n";
+PHP
+ );
+
+ return $core . '/';
+}
+
test('update from version N to N+1 applies migrations, update seeders and refreshes Extras', function () {
$capsule = bootSiteUpdateDatabase();
seedVersionNDatabase($capsule);
@@ -240,6 +278,60 @@ public function applyExtrasModule(): void
->and($extras->modulecode)->not->toBe('OUTDATED MODULE CODE');
});
+test('manager system upgrade action queues work and finishes on the requested system version', function () {
+ $capsule = bootSiteUpdateDatabase();
+ seedVersionNDatabase($capsule);
+ (new \CreateSystemCliTasksTables())->up();
+ (new SchedulerHealthService())->recordHeartbeat('feature-test', 'manual');
+ $workerHealth = new WorkerHealthService();
+ $workerHealth->markRun('feature-worker', 1234);
+ $workerHealth->markSuccess('feature-worker', 1234);
+
+ $currentVersion = '3.5.8';
+ $targetVersion = '3.5.9';
+ $corePath = managerUpgradeCoreFixture($currentVersion);
+ $taskService = new SystemTaskService();
+
+ try {
+ $versionBeforeUpdate = include $corePath . 'factory/version.php';
+ expect($versionBeforeUpdate['version'])->toBe($currentVersion);
+
+ // This is the service call made by updaterHandleSystemTaskRequest() when an authenticated
+ // super administrator presses the manager's system-update button.
+ $queued = $taskService->createTaskFromStoreRequest('site_update', [
+ 'target_ref' => $targetVersion,
+ 'backup_database' => '0',
+ ], [
+ 'user_id' => 1,
+ 'permissions' => [
+ 'exec_module' => 1,
+ 'system_tasks.view' => 1,
+ 'system_tasks.site_update' => 1,
+ ],
+ 'session_hash' => hash('sha256', 'authenticated-manager-cookie'),
+ ], true);
+
+ expect($queued['ok'])->toBeTrue()
+ ->and($queued['task']['status'])->toBe('queued')
+ ->and($queued['task']['requested_version'])->toBe($targetVersion);
+
+ $task = $taskService->acquireNextQueuedTask('feature-worker', 'feature-host', 1234);
+ expect($task)->toBeInstanceOf(SystemCliTask::class);
+
+ $result = (new SiteUpdateFlowService($corePath))->execute($task);
+ $taskService->markTaskSucceeded($task, $result['message'], $result['result']);
+
+ $installedVersion = include $corePath . 'factory/version.php';
+ $completedTask = $task->fresh();
+
+ expect($completedTask->status)->toBe('succeeded')
+ ->and($completedTask->result_json['target_ref'])->toBe($targetVersion)
+ ->and($installedVersion['version'])->toBe($targetVersion);
+ } finally {
+ (new Filesystem())->deleteDirectory(dirname(rtrim($corePath, '/')));
+ }
+});
+
test('moveFiles replaces files into the destination tree', function () {
$base = sys_get_temp_dir() . '/evo_update_' . uniqid();
$src = $base . '/src';
diff --git a/core/tests/Unit/Security/InstallerCompletionTest.php b/core/tests/Unit/Security/InstallerCompletionTest.php
new file mode 100644
index 0000000000..b4cdc7855e
--- /dev/null
+++ b/core/tests/Unit/Security/InstallerCompletionTest.php
@@ -0,0 +1,58 @@
+ '203.0.113.10',
+ 'timestamp' => $now - 10,
+ 'token' => $token,
+ ];
+
+ expect(InstallerCompletion::matches($lock, $token, '203.0.113.10', $now, 1440))->toBeTrue()
+ ->and(InstallerCompletion::matches($lock, str_repeat('b', 64), '203.0.113.10', $now, 1440))->toBeFalse()
+ ->and(InstallerCompletion::matches($lock, $token, '203.0.113.11', $now, 1440))->toBeFalse()
+ ->and(InstallerCompletion::matches($lock, $token, '203.0.113.10', $now + 1441, 1440))->toBeFalse();
+});
+
+test('a generic installer lock is not a removal capability', function () {
+ $lockFile = tempnam(sys_get_temp_dir(), 'evo-install-lock-');
+ file_put_contents(
+ $lockFile,
+ "toBeNull();
+ } finally {
+ unlink($lockFile);
+ }
+});
+
+test('a completed installer lock exposes only removal authorization fields', function () {
+ $lockFile = tempnam(sys_get_temp_dir(), 'evo-install-lock-');
+ $token = str_repeat('c', 64);
+ file_put_contents(
+ $lockFile,
+ "toBe([
+ 'ip' => '::1',
+ 'timestamp' => 1_800_000_000,
+ 'token' => $token,
+ ]);
+ } finally {
+ unlink($lockFile);
+ }
+});
diff --git a/core/tests/Unit/Security/PasswordGeneratorTest.php b/core/tests/Unit/Security/PasswordGeneratorTest.php
new file mode 100644
index 0000000000..228a427863
--- /dev/null
+++ b/core/tests/Unit/Security/PasswordGeneratorTest.php
@@ -0,0 +1,58 @@
+toBe($length)
+ ->and($password)->toMatch('/^[abcdefghjkmnpqrstuvxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789]+$/');
+ }
+});
+
+test('it does not repeat itself within a single second', function () {
+ // The clock-seeded version returned the same password for every call that landed in the
+ // same microsecond, which is what a loop like this reproduces.
+ $passwords = [];
+ for ($i = 0; $i < 200; $i++) {
+ $passwords[] = generate_password(10);
+ }
+
+ expect(count(array_unique($passwords)))->toBe(200);
+});
+
+test('it spreads across the alphabet rather than a seeded sequence', function () {
+ $sample = '';
+ for ($i = 0; $i < 200; $i++) {
+ $sample .= generate_password(10);
+ }
+
+ // 2000 characters over a 54-character alphabet: a generator stuck on a narrow seed shows up
+ // here as a handful of distinct characters.
+ expect(count(array_unique(str_split($sample))))->toBeGreaterThan(40);
+});
+
+test('guids are unique across calls in the same request', function () {
+ $guids = [];
+ for ($i = 0; $i < 200; $i++) {
+ $guids[] = createGUID();
+ }
+
+ expect(count(array_unique($guids)))->toBe(200)
+ ->and($guids[0])->toMatch('/^[0-9a-f]{32}$/');
+});
diff --git a/core/tests/Unit/Security/RedirectTargetTest.php b/core/tests/Unit/Security/RedirectTargetTest.php
new file mode 100644
index 0000000000..ce6543708e
--- /dev/null
+++ b/core/tests/Unit/Security/RedirectTargetTest.php
@@ -0,0 +1,60 @@
+newInstanceWithoutConstructor();
+}
+
+test('same-site and relative targets are allowed', function (string $url) {
+ expect(redirectGuard()->isLocalRedirectTarget($url, 'https://example.com/'))->toBeTrue();
+})->with([
+ 'relative path' => ['index.php?id=12'],
+ 'root-relative path' => ['/news/article/'],
+ 'root-relative path with a query' => ['/index.php?id=12&err=1'],
+ 'absolute url on this host' => ['https://example.com/manager/'],
+ 'absolute url on this host over http' => ['http://example.com/manager/'],
+ 'host casing differs' => ['https://EXAMPLE.com/manager/'],
+]);
+
+test('cross-site targets are refused', function (string $url) {
+ expect(redirectGuard()->isLocalRedirectTarget($url, 'https://example.com/'))->toBeFalse();
+})->with([
+ 'absolute url on another host' => ['https://evil.tld/'],
+ // The regression: no scheme, so the old check never ran.
+ 'protocol-relative' => ['//evil.tld/'],
+ 'protocol-relative without a trailing slash' => ['//evil.tld'],
+ 'backslash after the slash' => ['/\\evil.tld/'],
+ 'backslash before the slash' => ['\\/evil.tld/'],
+ 'both backslashes' => ['\\\\evil.tld/'],
+ 'userinfo pointing at another host' => ['https://example.com@evil.tld/'],
+ 'subdomain of a lookalike' => ['https://example.com.evil.tld/'],
+ 'non-http scheme' => ['javascript:alert(1)'],
+ 'data url' => ['data:text/html,'],
+ 'empty authority' => ['///'],
+ 'leading space before an authority' => [' //evil.tld/'],
+ 'leading tab before an authority' => ["\t//evil.tld/"],
+ 'leading tab before an absolute url' => ["\thttps://evil.tld/"],
+]);
+
+test('a site url without a host never matches', function () {
+ expect(redirectGuard()->isLocalRedirectTarget('https://example.com/', ''))->toBeFalse();
+});
diff --git a/install/src/controllers/install.php b/install/src/controllers/install.php
index e261952465..3042b1a891 100644
--- a/install/src/controllers/install.php
+++ b/install/src/controllers/install.php
@@ -1,6 +1,7 @@
-
-
diff --git a/manager/processors/remove_installer.processor.php b/manager/processors/remove_installer.processor.php
index e9fde65e93..b407662269 100755
--- a/manager/processors/remove_installer.processor.php
+++ b/manager/processors/remove_installer.processor.php
@@ -1,5 +1,7 @@
alert('" . addslashes($msg) . "');";
}
echo "";
-
-