From f535da4b0b3c2ecf74dc18e792f8c29724ee03fb Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Tue, 8 Sep 2026 03:29:01 +0200 Subject: [PATCH 1/2] fix(core): harden against MitM during upgrade --- assets/plugins/updater/plugin.updater.php | 44 ++-- core/src/Console/SiteUpdateCommand.php | 209 ++++++++++++++---- .../Services/Store/RemoteTransportService.php | 1 + .../Unit/Console/SiteUpdateCommandTest.php | 140 ++++++++++++ .../Unit/Security/UpdaterMetadataTest.php | 103 +++++++++ 5 files changed, 430 insertions(+), 67 deletions(-) create mode 100644 core/tests/Unit/Security/UpdaterMetadataTest.php diff --git a/assets/plugins/updater/plugin.updater.php b/assets/plugins/updater/plugin.updater.php index 5b273b8fe5..7026a09773 100644 --- a/assets/plugins/updater/plugin.updater.php +++ b/assets/plugins/updater/plugin.updater.php @@ -157,17 +157,7 @@ function updaterFetchReleasePublishedAt($repository, $versionRaw) foreach (array_unique($tags) as $tag) { $url = 'https://api.github.com/repos/' . $repo . '/releases/tags/' . rawurlencode($tag); - $ch = curl_init(); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($ch, CURLOPT_HEADER, false); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_REFERER, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, ['User-Agent: updateNotify widget']); - $response = curl_exec($ch); - if (PHP_VERSION_ID < 80500) { - curl_close($ch); - } + $response = (new \EvolutionCMS\Services\Store\RemoteTransportService())->fetchBody($url); if (!is_string($response) || $response === '' || strpos(ltrim($response), '{') !== 0) { continue; @@ -940,19 +930,8 @@ function poll() { $cacheFile = EVO_BASE_PATH . 'assets/cache/updater/check_' . date("d") . '.json'; if (!file_exists($cacheFile)) { - $ch = curl_init(); $url = 'https://api.github.com/repos/' . $version . '/' . $type; - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($ch, CURLOPT_HEADER, false); - //curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_REFERER, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, ['User-Agent: updateNotify widget']); - $info = curl_exec($ch); - if (PHP_VERSION_ID < 80500) { - curl_close($ch); - } + $info = (new \EvolutionCMS\Services\Store\RemoteTransportService())->fetchBody($url); if (substr($info, 0, 1) != '[') { return; } @@ -1368,7 +1347,10 @@ function mmkDir($folder, $perm = 0777) unlink(__DIR__ . "/evo.zip"); $ch = curl_init(); $url = "https://api.github.com/repos/' . $version . '/releases"; -curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); +curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); +curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); +curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); +curl_setopt($ch, CURLOPT_TIMEOUT, 20); curl_setopt($ch, CURLOPT_HEADER, false); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_REFERER, $url); @@ -1381,12 +1363,14 @@ function mmkDir($folder, $perm = 0777) foreach ($releases as $release) { if ($_GET["version"] == $release["tag_name"]) { $factoryDate = date("M j, Y", strtotime($release["published_at"])); - $factoryVersion = \' "\'.$release["tag_name"].\'", // Current version number\'."\n"; - $factoryVersion .= "\t".\'"release_date" => "\'.$factoryDate.\'", // Date of release\'."\n"; - $factoryVersion .= "\t".\'"branch" => "Evolution CMS", // Codebase name\'."\n"; - $factoryVersion .= "\t".\'"full_appname" => "\'.$release["name"].\' (\'.$factoryDate.\')", // Date of release\'."\n"; - $factoryVersion .= \'];\'; + // Release name and tag are remote metadata, so only those go through + // var_export: a crafted release must not break out and run as code. + $factoryVersion = " " . var_export((string)$release["tag_name"], true) . ", // Current version number\n"; + $factoryVersion .= " \'release_date\' => " . var_export($factoryDate, true) . ", // Date of release\n"; + $factoryVersion .= " \'branch\' => \'Evolution CMS\', // Codebase name\n"; + $factoryVersion .= " \'full_appname\' => " . var_export($release["name"] . " (" . $factoryDate . ")", true) . ", // Full application name and release date\n"; + $factoryVersion .= "];\n"; file_put_contents(__DIR__ . "/core/factory/version.php", $factoryVersion); break; } diff --git a/core/src/Console/SiteUpdateCommand.php b/core/src/Console/SiteUpdateCommand.php index 32d1b5f978..1e39c1d830 100644 --- a/core/src/Console/SiteUpdateCommand.php +++ b/core/src/Console/SiteUpdateCommand.php @@ -3,6 +3,7 @@ use EvolutionCMS\Models\Category; use EvolutionCMS\Models\SiteModule; use EvolutionCMS\Services\ComposerVersionSynchronizer; +use EvolutionCMS\Services\Store\RemoteTransportService; use Illuminate\Console\Command; /** @@ -109,16 +110,7 @@ public function startUpdate() { $evo = evo(); $updateRepository = $this->resolveUpdateRepository(); - $ch = curl_init(); - $url = 'https://api.github.com/repos/' . $updateRepository . '/tags'; - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($ch, CURLOPT_HEADER, false); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_REFERER, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, ['User-Agent: updateNotify widget']); - $info = curl_exec($ch); - curl_close($ch); + $info = $this->fetchRepositoryMetadata($updateRepository, 'tags'); if (substr($info, 0, 1) != '[') { return; } @@ -157,41 +149,45 @@ public function startUpdate() $lockedCustomPackageVersions = $this->resolveLockedComposerPackageVersions(array_keys($customPackageConstraints)); $url = $this->buildArchiveUrl($updateRepository, $git['version']); $this->line('Start download Evolution CMS'); - $url = file_get_contents($url); $file = EVO_BASE_PATH . 'new_version.zip'; - file_put_contents($file, $url); + // Nothing below this point may run on a failed package: the site keeps its + // current files, and composer, migrations and seeders stay untouched. + if (!$this->downloadUpdateArchive($url, $file)) { + @unlink($file); + $this->error('Could not download the update package. Nothing was changed.'); + + return; + } + $this->line('Start unpacking Evolution CMS'); $temp_dir = EVO_BASE_PATH . '_temp' . md5(time()); //run unzip and install - $zip = new \ZipArchive; - $res = $zip->open($file); - $zip->extractTo($temp_dir); - $zip->close(); + if (!$this->extractUpdateArchive($file, $temp_dir)) { + @unlink($file); + self::rmdirs($temp_dir); + $this->error('The downloaded update package is not a valid archive. Nothing was changed.'); + + return; + } + unlink($file); - if ($handle = opendir($temp_dir)) { - while (false !== ($name = readdir($handle))) { - if ($name != '.' && $name != '..') $dir = $name; - } - closedir($handle); + $dir = $this->resolveExtractedRoot($temp_dir); + + if ($dir === '') { + self::rmdirs($temp_dir); + $this->error('The update package has an unexpected layout. Nothing was changed.'); + + return; } self::moveFiles($temp_dir . '/' . $dir, EVO_BASE_PATH); self::rmdirs($temp_dir); - $ch = curl_init(); - $url = 'https://api.github.com/repos/' . $updateRepository . '/releases'; - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($ch, CURLOPT_HEADER, false); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_REFERER, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, ["User-Agent: updateNotify widget"]); - $releases = curl_exec($ch); - curl_close($ch); + $releases = $this->fetchRepositoryMetadata($updateRepository, 'releases'); $factoryName = $currentVersion['full_appname']; if (substr($releases, 0, 1) == "[") { @@ -200,12 +196,12 @@ public function startUpdate() if ($git['version'] == $release["tag_name"]) { $factoryDate = date("M j, Y", strtotime($release["published_at"])); $factoryName = $release["name"] . ' (' . $factoryDate . ')'; - $factoryVersion = ' "' . $release["tag_name"] . '", // Current version number' . "\n"; - $factoryVersion .= "\t" . '"release_date" => "' . $factoryDate . '", // Date of release' . "\n"; - $factoryVersion .= "\t" . '"branch" => "Evolution CMS", // Codebase name' . "\n"; - $factoryVersion .= "\t" . '"full_appname" => "' . $factoryName . '", // Date of release' . "\n"; - $factoryVersion .= '];'; + $factoryVersion = self::buildVersionFile([ + 'version' => (string)$release["tag_name"], + 'release_date' => $factoryDate, + 'branch' => 'Evolution CMS', + 'full_appname' => $factoryName, + ]); file_put_contents(EVO_CORE_PATH . "factory/version.php", $factoryVersion); break; } @@ -512,6 +508,145 @@ protected function getOrCreateCategoryId(string $categoryName): int return (int) Category::query()->firstOrCreate(['category' => $categoryName])->getKey(); } + /** + * Download the update package and store it locally. + * + * A transfer that failed or returned nothing must not be mistaken for a package: + * an empty file opens as a valid archive and would unpack into an empty tree. + * + * @since 3.5.8 + * @param string $url Archive URL. + * @param string $file Absolute path the package is written to. + * @return bool False when nothing usable was received or stored. + */ + protected function downloadUpdateArchive(string $url, string $file): bool + { + $body = @file_get_contents($url); + + if (!is_string($body) || $body === '') { + return false; + } + + return @file_put_contents($file, $body) !== false; + } + + /** + * Unpack the update package into a staging directory. + * + * @since 3.5.8 + * @param string $file Absolute path to the downloaded package. + * @param string $tempDir Staging directory the package is unpacked into. + * @return bool False when the package is not a usable archive. + */ + protected function extractUpdateArchive(string $file, string $tempDir): bool + { + // A zero byte file opens as a valid archive, so it is rejected before + // ZipArchive sees it and reports the empty archive as deprecated. + if (!is_file($file) || filesize($file) === 0) { + return false; + } + + $zip = new \ZipArchive(); + + if ($zip->open($file) !== true) { + return false; + } + + if ($zip->numFiles === 0) { + $zip->close(); + + return false; + } + + try { + $extracted = $zip->extractTo($tempDir); + } catch (\Throwable $exception) { + $extracted = false; + } + + $zip->close(); + + return $extracted === true; + } + + /** + * Name the directory a release archive unpacked into. + * + * A release always contains exactly one top level directory. Anything else means + * the package is not what the updater expects, or the staging directory was not + * empty to begin with. + * + * @since 3.5.8 + * @param string $tempDir Staging directory holding the unpacked package. + * @return string Directory name, empty when it cannot be determined. + */ + protected function resolveExtractedRoot(string $tempDir): string + { + if (!is_dir($tempDir)) { + return ''; + } + + $entries = scandir($tempDir); + + if (!is_array($entries)) { + return ''; + } + + $directories = []; + foreach ($entries as $entry) { + if ($entry !== '.' && $entry !== '..' && is_dir($tempDir . '/' . $entry)) { + $directories[] = $entry; + } + } + + return count($directories) === 1 ? $directories[0] : ''; + } + + /** + * Render factory/version.php from release metadata. + * + * Release name and tag come from the GitHub API, so every value is exported as a + * PHP literal. Concatenating them into double-quoted strings let a crafted release + * name break out of the literal and run as code when the file is included. + * + * @since 3.5.8 + * @param array $version Version data to persist. + * @return string + */ + public static function buildVersionFile(array $version): string + { + // Fixed text, never metadata, so a comment can never carry a line break. + $comments = [ + 'version' => 'Current version number', + 'release_date' => 'Date of release', + 'branch' => 'Codebase name', + 'full_appname' => 'Full application name and release date', + ]; + + $body = ''; + foreach ($version as $key => $value) { + $comment = $comments[$key] ?? ''; + $body .= ' ' . var_export((string)$key, true) . ' => ' . var_export($value, true) . ',' + . ($comment !== '' ? ' // ' . $comment : '') . "\n"; + } + + return 'fetchBody('https://api.github.com/repos/' . $repository . '/' . $resource); + } + /** * Resolve the GitHub repository used as the update source. * diff --git a/core/src/Services/Store/RemoteTransportService.php b/core/src/Services/Store/RemoteTransportService.php index 9399dbbf91..461f90ff6d 100644 --- a/core/src/Services/Store/RemoteTransportService.php +++ b/core/src/Services/Store/RemoteTransportService.php @@ -5,6 +5,7 @@ class RemoteTransportService protected array $allowedHosts = [ 'evo.im', 'extras.evo.im', + 'api.github.com', 'github.com', 'codeload.github.com', 'raw.githubusercontent.com', diff --git a/core/tests/Unit/Console/SiteUpdateCommandTest.php b/core/tests/Unit/Console/SiteUpdateCommandTest.php index ed356939e5..755cc3614d 100644 --- a/core/tests/Unit/Console/SiteUpdateCommandTest.php +++ b/core/tests/Unit/Console/SiteUpdateCommandTest.php @@ -262,3 +262,143 @@ function invokeSiteUpdateMethod(SiteUpdateCommand $command, string $method, arra @unlink($path); } }); + +/** + * Update package guards. + * + * A failed or truncated download must stop the command before any site file is + * replaced, and before composer, migrations and seeders run against code that + * was never updated. + */ + +function updateGuardWorkspace(): string +{ + $path = sys_get_temp_dir() . '/evo-update-guard-' . getmypid() . '-' . uniqid(); + mkdir($path, 0777, true); + + return $path; +} + +test('downloadUpdateArchive rejects a download that returned nothing', function () { + $workspace = updateGuardWorkspace(); + $target = $workspace . '/new_version.zip'; + + try { + // A failed transfer warns before returning false; that diagnostic is the + // expected behaviour here, so it is swallowed rather than reported as noise. + set_error_handler(static fn () => true); + try { + $missing = invokeSiteUpdateMethod($this->command, 'downloadUpdateArchive', [$workspace . '/absent.zip', $target]); + } finally { + restore_error_handler(); + } + + // An empty body stands in for a transfer that connected but delivered nothing. + $empty = invokeSiteUpdateMethod($this->command, 'downloadUpdateArchive', ['data://text/plain,', $target]); + + expect($missing)->toBeFalse() + ->and($empty)->toBeFalse() + ->and(file_exists($target))->toBeFalse(); + } finally { + SiteUpdateCommand::rmdirs($workspace); + } +}); + +test('downloadUpdateArchive stores a payload it actually received', function () { + $workspace = updateGuardWorkspace(); + $source = $workspace . '/source.zip'; + $target = $workspace . '/new_version.zip'; + file_put_contents($source, 'payload-bytes'); + + try { + expect(invokeSiteUpdateMethod($this->command, 'downloadUpdateArchive', [$source, $target]))->toBeTrue() + ->and(file_get_contents($target))->toBe('payload-bytes'); + } finally { + SiteUpdateCommand::rmdirs($workspace); + } +}); + +test('extractUpdateArchive refuses an empty file that opens as a valid archive', function () { + $workspace = updateGuardWorkspace(); + $file = $workspace . '/new_version.zip'; + file_put_contents($file, ''); + $tempDir = $workspace . '/_temp'; + + try { + // ZipArchive::open() reports success for a zero byte file, so the entry + // count is what has to rule it out. + expect(invokeSiteUpdateMethod($this->command, 'extractUpdateArchive', [$file, $tempDir]))->toBeFalse(); + } finally { + SiteUpdateCommand::rmdirs($workspace); + } +}); + +test('extractUpdateArchive refuses a truncated or wrong payload without throwing', function () { + $workspace = updateGuardWorkspace(); + $tempDir = $workspace . '/_temp'; + + try { + foreach (['404 Not Found', "PK\x03\x04truncated-archive"] as $index => $content) { + $file = $workspace . '/payload' . $index . '.zip'; + file_put_contents($file, $content); + + expect(invokeSiteUpdateMethod($this->command, 'extractUpdateArchive', [$file, $tempDir]))->toBeFalse(); + } + } finally { + SiteUpdateCommand::rmdirs($workspace); + } +}); + +test('extractUpdateArchive unpacks a real archive', function () { + $workspace = updateGuardWorkspace(); + $file = $workspace . '/new_version.zip'; + $tempDir = $workspace . '/_temp'; + + $zip = new ZipArchive(); + $zip->open($file, ZipArchive::CREATE); + $zip->addFromString('evolution-3.5.9/index.php', 'close(); + + try { + expect(invokeSiteUpdateMethod($this->command, 'extractUpdateArchive', [$file, $tempDir]))->toBeTrue() + ->and(file_get_contents($tempDir . '/evolution-3.5.9/index.php'))->toBe('command, 'resolveExtractedRoot', [$one]))->toBe('evolution-3.5.9') + ->and(invokeSiteUpdateMethod($this->command, 'resolveExtractedRoot', [$none]))->toBe('') + ->and(invokeSiteUpdateMethod($this->command, 'resolveExtractedRoot', [$many]))->toBe('') + ->and(invokeSiteUpdateMethod($this->command, 'resolveExtractedRoot', [$workspace . '/absent']))->toBe(''); + } finally { + SiteUpdateCommand::rmdirs($workspace); + } +}); + +test('startUpdate aborts on a bad package before touching the site', function () { + $source = (string) file_get_contents(dirname(__DIR__, 3) . '/src/Console/SiteUpdateCommand.php'); + $start = strpos($source, 'Start download Evolution CMS'); + $move = strpos($source, 'self::moveFiles($temp_dir'); + $prelude = substr($source, $start, $move - $start); + + // Every guard has to sit between the download and the first site write. + expect($prelude)->toContain('downloadUpdateArchive') + ->and($prelude)->toContain('extractUpdateArchive') + ->and($prelude)->toContain('resolveExtractedRoot') + ->and(substr_count($prelude, 'return;'))->toBe(3); +}); diff --git a/core/tests/Unit/Security/UpdaterMetadataTest.php b/core/tests/Unit/Security/UpdaterMetadataTest.php new file mode 100644 index 0000000000..c95e723b04 --- /dev/null +++ b/core/tests/Unit/Security/UpdaterMetadataTest.php @@ -0,0 +1,103 @@ + php_sapi_name(), "z" => "', + 'Evo ${x} and {$y}', + 'Evo \' . phpinfo() . \'', + 'Evo with trailing backslash \\', + "Evo with newline\n and \$dollar", + ]; + + foreach ($payloads as $payload) { + $expected = [ + 'version' => $payload, + 'release_date' => 'Jan 1, 2026', + 'branch' => 'Evolution CMS', + 'full_appname' => $payload . ' (Jan 1, 2026)', + ]; + + $file = tempnam(sys_get_temp_dir(), 'evover'); + file_put_contents($file, SiteUpdateCommand::buildVersionFile($expected)); + $data = include $file; + unlink($file); + + // Values survive verbatim, and no extra key is smuggled in by a quote breakout. + expect($data)->toBe($expected); + } +}); + +test('generated version file keeps its explanatory comments', function () { + // The file is read by integrators, so each field stays documented in place. + $source = SiteUpdateCommand::buildVersionFile([ + 'version' => '3.5.8', + 'release_date' => 'Oct 3, 2026', + 'branch' => 'Evolution CMS', + 'full_appname' => 'Evolution CMS 3.5.8 (Oct 3, 2026)', + ]); + + expect($source)->toContain("'version' => '3.5.8', // Current version number") + ->and($source)->toContain("'release_date' => 'Oct 3, 2026', // Date of release") + ->and($source)->toContain("'branch' => 'Evolution CMS', // Codebase name") + ->and($source)->toContain("// Full application name and release date") + ->and($source)->toStartWith('and(trim($source))->toEndWith('];'); +}); + +test('release metadata can neither forge nor escape a comment', function () { + // A payload ending in a line break must not be able to append its own PHP line. + $payload = "3.5.8', // Current version number\n 'injected' => php_sapi_name(), // x"; + $source = SiteUpdateCommand::buildVersionFile(['version' => $payload]); + + $file = tempnam(sys_get_temp_dir(), 'evover'); + file_put_contents($file, $source); + $data = include $file; + unlink($file); + + // The whole payload stays one string value; the forged line never became code. + expect($data)->toBe(['version' => $payload]) + ->and($data)->toHaveCount(1) + ->and($data)->not->toHaveKey('injected'); +}); + +test('updater generation never concatenates release metadata into php source', function () { + $command = (string) file_get_contents(updaterRoot() . '/core/src/Console/SiteUpdateCommand.php'); + $plugin = (string) file_get_contents(updaterRoot() . '/assets/plugins/updater/plugin.updater.php'); + + expect($command)->toContain('var_export') + ->and($command)->not->toContain('\'"version" => "\'') + ->and($plugin)->toContain('var_export') + ->and($plugin)->not->toContain('\'"version" => "\''); +}); + +test('both updater paths verify tls when fetching release metadata', function () { + $command = (string) file_get_contents(updaterRoot() . '/core/src/Console/SiteUpdateCommand.php'); + $plugin = (string) file_get_contents(updaterRoot() . '/assets/plugins/updater/plugin.updater.php'); + + expect($command)->not->toContain('CURLOPT_SSL_VERIFYPEER, false') + ->and($plugin)->not->toContain('CURLOPT_SSL_VERIFYPEER, false'); +}); + +test('secure transport allows the github api host used by the updater', function () { + $service = new RemoteTransportService(); + + expect($service->normalizeAndValidateUrl('https://api.github.com/repos/evolution-cms/evolution/tags')) + ->toBe('https://api.github.com/repos/evolution-cms/evolution/tags') + ->and($service->normalizeAndValidateUrl('http://api.github.com/repos/evolution-cms/evolution/tags'))->toBe('') + ->and($service->normalizeAndValidateUrl('https://api.github.com.evil.example/repos/x/tags'))->toBe(''); +}); From cf4598a4ce8f3567973a5678d1813402c5055ead Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Tue, 8 Sep 2026 11:54:06 +0200 Subject: [PATCH 2/2] fix(manager): rotate CSRF tokens on login and safely restore tabs Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01P9aF9EbbBDVN1X6mvbWyFp --- .../Unit/Manager/GlobalTabsCsrfTokenTest.php | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/core/tests/Unit/Manager/GlobalTabsCsrfTokenTest.php b/core/tests/Unit/Manager/GlobalTabsCsrfTokenTest.php index 4067274fc3..b737541033 100644 --- a/core/tests/Unit/Manager/GlobalTabsCsrfTokenTest.php +++ b/core/tests/Unit/Manager/GlobalTabsCsrfTokenTest.php @@ -5,26 +5,25 @@ expect($frame) ->toMatch('~~') - ->toContain("config('cms.manager_tab_restore.modules'") - ->toContain('tab_restore_user:'); + ->toContain('tab_restore_user:') + ->not->toContain('tab_restore_modules'); $script = file_get_contents(dirname(__DIR__, 4) . '/manager/media/style/default/js/evo.js'); expect($script) ->not->toContain('__EVO_CSRF_TOKEN__') - ->toContain('tabUrlForStorage: function (url)') - ->toContain('tabUrlForRestore: function (url)') + ->toContain('tabsUrlForStorage: function (url)') + ->toContain('tabsUrlForRestore: function (url)') ->toContain('w.evoManagerTabState.storage(url, evo.tabsRestoreOptions())') ->toContain('w.evoManagerTabState.restore(url, evo.tabsRestoreOptions())') - ->toContain('url: evo.tabUrlForStorage(url)') - ->toContain('active: evo.tabUrlForStorage(activeUrl)') - ->toContain('var tabUrl = evo.tabUrlForRestore(tab.url)') - ->toContain('var activeUrl = evo.tabUrlForRestore(data.active)') - ->toContain('startupUrl = evo.tabUrlForRestore(w.location.href)') + ->toContain('var entry = w.evoManagerTabState.remember(getTabUrl(tab), evo.tabsRestoreOptions())') + ->toContain('active: w.evoManagerTabState.remember(activeUrl, evo.tabsRestoreOptions())') + ->toContain('var plan = w.evoManagerTabState.resume(tab, evo.tabsRestoreOptions(), d)') + ->toContain('var activePlan = w.evoManagerTabState.resume(active, evo.tabsRestoreOptions(), d)') + ->toContain('startupPlan = w.evoManagerTabState.resume(w.location.href, evo.tabsRestoreOptions(), d)') ->toContain('token: evo.tabsCsrfToken()') - ->toContain('modules: evo.config.tab_restore_modules') ->toContain("'EVO_Tabs:' + encodeURIComponent(evo.EVO_MANAGER_URL) + ':' + evo.config.tab_restore_user") - ->toContain("localStorage.setItem('page_url', evo.tabUrlForStorage(b))"); + ->toContain("localStorage.setItem('page_url', evo.tabsUrlForStorage(b))"); // Runtime URL validation and stale-token cases live in manager-tab-state.test.js. $helper = file_get_contents(dirname(__DIR__, 4) . '/manager/media/script/manager-tab-state.js');