Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions core/database/migrations/2026_01_17_000000_fix_columns.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up(): void
{
Schema::table('active_user_locks', function (Blueprint $table) {
$table->string('sid', 128)->change();
});
Schema::table('active_user_sessions', function (Blueprint $table) {
$table->string('sid', 128)->change();
});
Schema::table('active_users', function (Blueprint $table) {
$table->string('sid', 128)->change();
});
}

/**
* Reverse the migrations.
*
* @return void
*/
public function down(): void
{
Schema::table('active_user_locks', function (Blueprint $table) {
$table->string('sid', 32)->change();
});
Schema::table('active_user_sessions', function (Blueprint $table) {
$table->string('sid', 32)->change();
});
Schema::table('active_users', function (Blueprint $table) {
$table->string('sid', 32)->change();
});
}
};
31 changes: 31 additions & 0 deletions core/src/Console/SiteUpdateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ public function startUpdate()
putenv('COMPOSER_HOME=' . EVO_CORE_PATH . 'composer');
$this->installComposerDependencies($customPackageConstraints, $lockedCustomPackageVersions);
$this->runCoreMigrations();
$this->runUpdateSeeders();
$this->updateBundledExtrasModule();

$this->line('<fg=green>Remove Install Directory</>');
Expand All @@ -250,6 +251,36 @@ protected function runCoreMigrations(): void
$this->runCoreShellCommand('php artisan migrate --force');
}

/**
* Apply the version update seeders shipped in the install directory.
*
* The web installer and install-folder CLI updater run seed('update') to apply
* data fixes such as permission renames. "php artisan make:site update" replaces
* files directly and used to skip this step, so manager/CLI updates drifted from
* the installer. The seeders use only the DB facade and are idempotent, so running
* them here before the install directory is removed keeps every path consistent.
*
* @since 3.5.7
* @return void
*/
protected function runUpdateSeeders(): void
{
$seedDir = EVO_BASE_PATH . 'install/stubs/seeds/update';
if (!is_dir($seedDir)) {
return;
}

$this->line('<fg=green>Apply update seeders</>');

foreach (glob($seedDir . '/*.php') as $filename) {
require_once $filename;
$class = 'EvolutionCMS\\Installer\\Update\\' . basename($filename, '.php');
if (class_exists($class) && is_subclass_of($class, \Illuminate\Database\Seeder::class)) {
(new $class())->run();
}
}
}

/**
* Remove placeholder files from the update archive when real local files exist.
*
Expand Down
269 changes: 269 additions & 0 deletions core/tests/Feature/SiteUpdateE2ETest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
<?php

/*
|--------------------------------------------------------------------------
| Tier 2 update e2e: install "version N" then update to "N+1"
|--------------------------------------------------------------------------
|
| This exercises the real database side of an Evolution CMS update without
| network or Composer. It builds a populated "version N" SQLite database that
| looks like a site installed before the system task feature existed, then runs
| the same code the "php artisan make:site update" / manager update path runs:
|
| - the core-only migration that creates the system task tables
| - SiteUpdateCommand::runUpdateSeeders() (the install update seeders)
| - SiteUpdateCommand::updateBundledExtrasModule()
|
| and asserts the resulting schema/data deltas. The file-replacement mechanics
| (download/extract/move) are covered separately via the static helpers, since
| the full command run mutates EVO_BASE_PATH and belongs to the Tier 3 sandbox.
*/

use Illuminate\Database\Capsule\Manager as Capsule;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Facade;

if (!defined('EVO_BASE_PATH')) {
define('EVO_BASE_PATH', str_replace('\\', '/', dirname(__DIR__, 3)) . '/');
}
if (!defined('EVO_CORE_PATH')) {
define('EVO_CORE_PATH', EVO_BASE_PATH . 'core/');
}

/**
* Boot a standalone SQLite database with the DB/Schema facades wired so real
* migrations and seeders (which use facades) can run inside the bare PHPUnit harness.
*/
function bootSiteUpdateDatabase(): Capsule
{
$capsule = new Capsule();
$capsule->addConnection([
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
$capsule->setAsGlobal();
$capsule->bootEloquent();

$container = $capsule->getContainer();
$container->instance('db', $capsule->getDatabaseManager());
$container->bind('db.schema', fn () => $capsule->getConnection()->getSchemaBuilder());
Facade::setFacadeApplication($container);
Model::setConnectionResolver($capsule->getDatabaseManager());

// The global \DB / \Schema aliases are normally registered by Laravel's
// AliasLoader; the bare test harness has no app, so register them for the
// migrations that reference \DB::getTablePrefix() etc.
if (!class_exists('DB', false)) {
class_alias(\Illuminate\Support\Facades\DB::class, 'DB');
}
if (!class_exists('Schema', false)) {
class_alias(\Illuminate\Support\Facades\Schema::class, 'Schema');
}

return $capsule;
}

/**
* Create and seed a database that mirrors a site installed at "version N":
* a healthy ACL baseline, legacy permission keys that the update seeder renames,
* and an existing (outdated) bundled Extras module — but no system task tables.
*/
function seedVersionNDatabase(Capsule $capsule): void
{
$schema = $capsule->getConnection()->getSchemaBuilder();

$schema->create('permissions_groups', function (Blueprint $table) {
$table->increments('id');
$table->string('name')->nullable();
$table->string('lang_key')->nullable();
$table->dateTime('created_at')->nullable();
$table->dateTime('updated_at')->nullable();
});
$schema->create('permissions', function (Blueprint $table) {
$table->increments('id');
$table->string('name')->nullable();
$table->string('key')->nullable();
$table->string('lang_key')->nullable();
$table->integer('disabled')->default(0);
$table->integer('group_id')->default(0);
$table->dateTime('created_at')->nullable();
$table->dateTime('updated_at')->nullable();
});
$schema->create('user_roles', function (Blueprint $table) {
$table->increments('id');
$table->string('name')->nullable();
});
$schema->create('role_permissions', function (Blueprint $table) {
$table->increments('id');
$table->integer('role_id');
$table->string('permission')->nullable();
$table->dateTime('created_at')->nullable();
$table->dateTime('updated_at')->nullable();
});
$schema->create('categories', function (Blueprint $table) {
$table->increments('id');
$table->string('category')->nullable();
$table->integer('rank')->default(0);
});
$schema->create('site_modules', function (Blueprint $table) {
$table->increments('id');
$table->string('name')->nullable();
$table->text('description')->nullable();
$table->longText('modulecode')->nullable();
$table->longText('properties')->nullable();
$table->string('guid')->nullable();
$table->integer('enable_sharedparams')->default(0);
$table->integer('category')->default(0);
$table->integer('disabled')->default(0);
$table->dateTime('createdon')->nullable();
$table->dateTime('editedon')->nullable();
});
// Present-but-empty so the migration's updater wiring guard returns cleanly.
$schema->create('site_plugins', function (Blueprint $table) {
$table->increments('id');
$table->string('name')->nullable();
$table->text('description')->nullable();
$table->longText('plugincode')->nullable();
$table->longText('properties')->nullable();
$table->integer('disabled')->default(0);
});
$schema->create('site_plugin_events', function (Blueprint $table) {
$table->increments('id');
$table->integer('pluginid');
$table->integer('evtid');
$table->integer('priority')->default(0);
});
$schema->create('system_eventnames', function (Blueprint $table) {
$table->increments('id');
$table->string('name')->nullable();
});

$db = $capsule->getConnection();

// Healthy ACL baseline so the migration's repair self-skips (14 groups, role 1,
// access_permissions assigned to role 1).
for ($id = 1; $id <= 14; $id++) {
$db->table('permissions_groups')->insert(['id' => $id, 'name' => 'Group ' . $id, 'lang_key' => 'group_' . $id]);
}
$db->table('user_roles')->insert(['id' => 1, 'name' => 'Administrator']);
$db->table('permissions')->insert([
['name' => 'Manager access permissions', 'key' => 'access_permissions', 'lang_key' => 'manager_access_permissions', 'group_id' => 11],
// Legacy keys the update seeder must rename.
['name' => 'Logout', 'key' => 'logout', 'lang_key' => 'role_logout', 'group_id' => 1],
['name' => 'Web access permissions', 'key' => 'web_access_permissions', 'lang_key' => 'role_web_access_permissions', 'group_id' => 11],
]);
$db->table('role_permissions')->insert([
['role_id' => 1, 'permission' => 'access_permissions'],
['role_id' => 1, 'permission' => 'logout'],
['role_id' => 1, 'permission' => 'web_access_permissions'],
]);

// An outdated bundled Extras module that the update should refresh.
$db->table('site_modules')->insert([
'name' => 'Extras',
'description' => '<strong>0.1.0</strong> outdated',
'modulecode' => 'OUTDATED MODULE CODE',
'properties' => '',
'guid' => 'store435243542tf542t5t',
'enable_sharedparams' => 1,
'category' => 0,
]);
}

/**
* Run the database-affecting steps of an update, exactly as the update command does.
*/
function runSiteUpdateDatabaseSteps(): void
{
// 1. Core-only migration that ships the system task tables and permissions.
(new \CreateSystemCliTasksTables())->up();

// 2 + 3. The real update command steps, with console output suppressed.
$command = new class extends \EvolutionCMS\Console\SiteUpdateCommand {
public function line($string, $style = null, $verbosity = null)
{
// Suppress: there is no console output bound in the test harness.
}

public function applyUpdateSeeders(): void
{
$this->runUpdateSeeders();
}

public function applyExtrasModule(): void
{
$this->updateBundledExtrasModule();
}
};

$command->applyUpdateSeeders();
$command->applyExtrasModule();
}

test('update from version N to N+1 applies migrations, update seeders and refreshes Extras', function () {
$capsule = bootSiteUpdateDatabase();
seedVersionNDatabase($capsule);
$db = $capsule->getConnection();

// Pre-state sanity: version "N" has no system task tables and legacy permission keys.
expect($db->getSchemaBuilder()->hasTable('system_cli_tasks'))->toBeFalse()
->and($db->table('permissions')->where('key', 'logout')->exists())->toBeTrue();

runSiteUpdateDatabaseSteps();

// Migration created the system task tables.
expect($db->getSchemaBuilder()->hasTable('system_cli_tasks'))->toBeTrue()
->and($db->getSchemaBuilder()->hasTable('system_cli_task_logs'))->toBeTrue()
->and($db->getSchemaBuilder()->hasTable('system_scheduler_health'))->toBeTrue()
->and($db->getSchemaBuilder()->hasTable('system_worker_health'))->toBeTrue();

// Migration registered + granted the system task permissions to the admin role.
expect($db->table('permissions')->where('key', 'system_tasks.site_update')->exists())->toBeTrue()
->and($db->table('role_permissions')->where('role_id', 1)->where('permission', 'system_tasks.view')->exists())->toBeTrue();

// Update seeder renamed the legacy permission keys (both definitions and grants).
expect($db->table('permissions')->where('key', 'logout')->exists())->toBeFalse()
->and($db->table('permissions')->where('key', 'widget_recent_info')->exists())->toBeTrue()
->and($db->table('permissions')->where('key', 'web_access_permissions')->exists())->toBeFalse()
->and($db->table('permissions')->where('key', 'manage_groups')->exists())->toBeTrue()
->and($db->table('permissions')->where('key', 'manage_document_permissions')->exists())->toBeTrue()
->and($db->table('role_permissions')->where('permission', 'logout')->exists())->toBeFalse()
->and($db->table('role_permissions')->where('permission', 'widget_recent_info')->exists())->toBeTrue()
->and($db->table('role_permissions')->where('permission', 'manage_groups')->exists())->toBeTrue();

// Bundled Extras module refreshed from install/assets/modules/store.tpl.
$extras = $db->table('site_modules')->where('name', 'Extras')->first();
expect($extras->description)->toContain('<strong>0.2.0</strong>')
->and($extras->modulecode)->toContain('store/core.php')
->and($extras->modulecode)->not->toBe('OUTDATED MODULE CODE');
});

test('moveFiles replaces files into the destination tree', function () {
$base = sys_get_temp_dir() . '/evo_update_' . uniqid();
$src = $base . '/src';
$dest = $base . '/dest';
mkdir($src . '/core', 0777, true);
mkdir($dest . '/core', 0777, true);
file_put_contents($src . '/index.php', 'NEW');
file_put_contents($src . '/core/file.php', 'NEW CORE');
file_put_contents($dest . '/index.php', 'OLD');

\EvolutionCMS\Console\SiteUpdateCommand::moveFiles($src, $dest);

expect(file_get_contents($dest . '/index.php'))->toBe('NEW')
->and(file_get_contents($dest . '/core/file.php'))->toBe('NEW CORE');

\EvolutionCMS\Console\SiteUpdateCommand::rmdirs($base);
});

test('rmdirs removes a directory tree recursively', function () {
$dir = sys_get_temp_dir() . '/evo_rmdirs_' . uniqid();
mkdir($dir . '/nested', 0777, true);
file_put_contents($dir . '/nested/leaf.txt', 'x');

\EvolutionCMS\Console\SiteUpdateCommand::rmdirs($dir);

expect(is_dir($dir))->toBeFalse();
});
Loading