mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-11-01 03:03:23 +01:00
The store now serves every module with update {"source": "platform"} and
its permanent hash_id. Install, update, rollback, licence renewal, the LB
deploy and the store page ask the store for that hash_id, while
config/modules.php, the directory and the .lic stay keyed by the module's
name from module.json. Before, a store slug that differed from the name
recorded the install under the slug: the module showed as not installed
and lost its rollback and renew actions.
A catalogue or LB payload from an older store/MAIN without hash_id still
works through the slug, taken to be the name as before.
604 lines
27 KiB
PHP
604 lines
27 KiB
PHP
<?php
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use XcVm\Core\Container\ServiceContainer;
|
|
use XcVm\Core\Module\ModuleLoader;
|
|
use XcVm\Core\Module\ModuleManager;
|
|
|
|
// Records each statement of a module schema file; can refuse one.
|
|
class AuditSchemaDb {
|
|
public string $failOn = '';
|
|
public array $statements = [];
|
|
|
|
public function query(string $rSql): bool {
|
|
$this->statements[] = rtrim($rSql, ';');
|
|
return $this->failOn === '' || strpos($rSql, $this->failOn) === false;
|
|
}
|
|
}
|
|
|
|
// What the generated modules' programmatic migrations and install() hooks ran.
|
|
class AuditSchemaLog {
|
|
public static array $ran = [];
|
|
}
|
|
|
|
/**
|
|
* Installing a module over an older install of it (a store update, a store
|
|
* rollback, an archive uploaded again) brings the schema from the recorded
|
|
* version to the new one: the deltas in between run, each once, before the
|
|
* master schema, and the recorded version follows them. An uploaded archive
|
|
* that is refused or fails to install leaves the installed copy in place.
|
|
* When the files go back (a rollback, a failed update) the recorded version
|
|
* goes with them and the version the schema reached stays on record beside
|
|
* it, so no delta is applied a second time.
|
|
*/
|
|
final class AuditModuleUpdateSchemaTest extends TestCase {
|
|
private const MASTER = 'CREATE TABLE IF NOT EXISTS `aus` (`id` int)';
|
|
|
|
private string $rWork;
|
|
private string $rModules;
|
|
private string $rOverrides;
|
|
private AuditSchemaDb $rDb;
|
|
|
|
protected function setUp(): void {
|
|
$this->rWork = sys_get_temp_dir() . '/xc_vm_schema_' . bin2hex(random_bytes(6));
|
|
$this->rModules = $this->rWork . '/modules';
|
|
$this->rOverrides = $this->rWork . '/modules.php';
|
|
mkdir($this->rModules, 0775, true);
|
|
$this->rDb = new AuditSchemaDb();
|
|
ServiceContainer::getInstance()->set('db', $this->rDb);
|
|
AuditSchemaLog::$ran = [];
|
|
}
|
|
|
|
protected function tearDown(): void {
|
|
ServiceContainer::getInstance()->remove('db');
|
|
exec('rm -rf ' . escapeshellarg($this->rWork));
|
|
}
|
|
|
|
// ── installModule() over an existing install ─────────────────────
|
|
|
|
public function testInstallingOverAnOlderInstallRunsTheDeltasInBetweenFirst(): void {
|
|
$this->module($this->rModules . '/aus-over', 'aus-over', '1.2.0', [
|
|
'database.sql' => self::MASTER . ';',
|
|
'migrations/1.0.0.sql' => 'ALTER TABLE `aus` ADD COLUMN `zero` int;',
|
|
'migrations/1.1.0.sql' => 'ALTER TABLE `aus` ADD COLUMN `one` int;',
|
|
'migrations/1.2.0.sql' => 'ALTER TABLE `aus` ADD COLUMN `two` int;',
|
|
], ['1.0.0', '1.2.0']);
|
|
$this->overrides(['aus-over' => ['installed_version' => '1.0.0']]);
|
|
|
|
$this->manager()->installModule('aus-over', '1.2.0');
|
|
|
|
$this->assertSame([
|
|
'ALTER TABLE `aus` ADD COLUMN `one` int',
|
|
'ALTER TABLE `aus` ADD COLUMN `two` int',
|
|
self::MASTER,
|
|
], $this->rDb->statements);
|
|
$this->assertSame(['migration 1.2.0', 'install'], AuditSchemaLog::$ran);
|
|
$this->assertSame(['installed_version' => '1.2.0'], $this->recorded('aus-over'));
|
|
}
|
|
|
|
public function testAFirstInstallRunsOnlyTheMasterSchema(): void {
|
|
$this->module($this->rModules . '/aus-first', 'aus-first', '1.1.0', [
|
|
'database.sql' => self::MASTER . ';',
|
|
'migrations/1.1.0.sql' => 'ALTER TABLE `aus` ADD COLUMN `one` int;',
|
|
], ['1.1.0']);
|
|
|
|
$this->manager()->installModule('aus-first');
|
|
|
|
$this->assertSame([self::MASTER], $this->rDb->statements);
|
|
$this->assertSame(['install'], AuditSchemaLog::$ran);
|
|
$this->assertSame(['installed_version' => '1.1.0'], $this->recorded('aus-first'));
|
|
}
|
|
|
|
public function testInstallingTheRecordedOrAnOlderVersionRunsNoDelta(): void {
|
|
$this->module($this->rModules . '/aus-back', 'aus-back', '1.1.0', [
|
|
'database.sql' => self::MASTER . ';',
|
|
'migrations/1.1.0.sql' => 'ALTER TABLE `aus` ADD COLUMN `one` int;',
|
|
], ['1.1.0']);
|
|
|
|
$this->overrides(['aus-back' => ['installed_version' => '1.1.0']]);
|
|
$this->manager()->installModule('aus-back', '1.1.0');
|
|
$this->assertSame(['installed_version' => '1.1.0'], $this->recorded('aus-back'));
|
|
|
|
// The store's rollback installs the version before the recorded one.
|
|
// The schema is not taken back with it, and that stays on record.
|
|
$this->manager()->installModule('aus-back', '1.0.0');
|
|
$this->assertSame(['installed_version' => '1.0.0', 'schema_version' => '1.1.0'], $this->recorded('aus-back'));
|
|
|
|
$this->assertSame([self::MASTER, self::MASTER], $this->rDb->statements);
|
|
$this->assertSame(['install', 'install'], AuditSchemaLog::$ran);
|
|
}
|
|
|
|
public function testGoingBackAVersionAndForwardAgainAppliesNoDeltaTwice(): void {
|
|
$this->module($this->rModules . '/aus-again', 'aus-again', '1.3.0', [
|
|
'database.sql' => self::MASTER . ';',
|
|
'migrations/1.2.0.sql' => 'ALTER TABLE `aus` ADD COLUMN `two` int;',
|
|
'migrations/1.3.0.sql' => 'ALTER TABLE `aus` ADD COLUMN `three` int;',
|
|
], ['1.2.0', '1.3.0']);
|
|
$this->overrides(['aus-again' => ['installed_version' => '1.2.0']]);
|
|
|
|
// A rollback to 1.1.0, then the update to 1.2.0 again: its delta is in.
|
|
$this->manager()->installModule('aus-again', '1.1.0');
|
|
$this->manager()->installModule('aus-again', '1.2.0');
|
|
|
|
$this->assertSame([self::MASTER, self::MASTER], $this->rDb->statements);
|
|
$this->assertSame(['install', 'install'], AuditSchemaLog::$ran);
|
|
$this->assertSame(['installed_version' => '1.2.0'], $this->recorded('aus-again'));
|
|
|
|
// Going back again and on to a later version runs only what is new.
|
|
$this->manager()->installModule('aus-again', '1.1.0');
|
|
$this->rDb->statements = [];
|
|
AuditSchemaLog::$ran = [];
|
|
$this->manager()->installModule('aus-again', '1.3.0');
|
|
|
|
$this->assertSame(['ALTER TABLE `aus` ADD COLUMN `three` int', self::MASTER], $this->rDb->statements);
|
|
$this->assertSame(['migration 1.3.0', 'install'], AuditSchemaLog::$ran);
|
|
$this->assertSame(['installed_version' => '1.3.0'], $this->recorded('aus-again'));
|
|
}
|
|
|
|
public function testAnUpdateResumesAfterTheSchemaVersionWhenTheFilesWentBack(): void {
|
|
// A git or url update whose files were put back, tried again.
|
|
$this->module($this->rModules . '/aus-git', 'aus-git', '1.2.0', [
|
|
'database.sql' => self::MASTER . ';',
|
|
'migrations/1.1.0.sql' => 'ALTER TABLE `aus` ADD COLUMN `one` int;',
|
|
'migrations/1.2.0.sql' => 'ALTER TABLE `aus` ADD COLUMN `two` int;',
|
|
], ['1.1.0', '1.2.0']);
|
|
$this->overrides(['aus-git' => ['installed_version' => '1.0.0', 'schema_version' => '1.1.0']]);
|
|
|
|
$this->manager()->updateModule('aus-git');
|
|
|
|
$this->assertSame(['ALTER TABLE `aus` ADD COLUMN `two` int'], $this->rDb->statements);
|
|
$this->assertSame(['migration 1.2.0'], AuditSchemaLog::$ran);
|
|
$this->assertSame(['installed_version' => '1.2.0'], $this->recorded('aus-git'));
|
|
}
|
|
|
|
public function testUninstallingForgetsTheSchemaVersion(): void {
|
|
$this->module($this->rModules . '/aus-drop', 'aus-drop', '1.0.0', ['database.sql' => self::MASTER . ';']);
|
|
$this->overrides(['aus-drop' => ['installed_version' => '1.0.0', 'schema_version' => '1.1.0']]);
|
|
|
|
$this->manager()->uninstallModule('aus-drop');
|
|
|
|
$this->assertSame(['state' => 'disabled'], $this->recorded('aus-drop'));
|
|
}
|
|
|
|
public function testAFailingDeltaStopsAtTheLastVersionApplied(): void {
|
|
$this->module($this->rModules . '/aus-stop', 'aus-stop', '1.2.0', [
|
|
'database.sql' => self::MASTER . ';',
|
|
'migrations/1.1.0.sql' => 'ALTER TABLE `aus` ADD COLUMN `one` int;',
|
|
'migrations/1.2.0.sql' => 'BOOM;',
|
|
]);
|
|
$this->overrides(['aus-stop' => ['installed_version' => '1.0.0']]);
|
|
$this->rDb->failOn = 'BOOM';
|
|
|
|
try {
|
|
$this->manager()->installModule('aus-stop');
|
|
$this->fail('The failing delta must stop the install.');
|
|
} catch (RuntimeException $e) {
|
|
$this->assertStringContainsString('1.2.0.sql', $e->getMessage());
|
|
}
|
|
|
|
$this->assertSame(['installed_version' => '1.1.0', 'state' => 'failed'], $this->recorded('aus-stop'));
|
|
$this->assertNotContains(self::MASTER, $this->rDb->statements);
|
|
$this->assertSame([], AuditSchemaLog::$ran);
|
|
}
|
|
|
|
public function testAModuleWithoutAMasterSchemaRunsEachNewDeltaOnce(): void {
|
|
$this->module($this->rModules . '/aus-deltas', 'aus-deltas', '1.1.0', [
|
|
'migrations/1.0.0.sql' => self::MASTER . ';',
|
|
'migrations/1.1.0.sql' => 'ALTER TABLE `aus` ADD COLUMN `one` int;',
|
|
]);
|
|
$this->overrides(['aus-deltas' => ['installed_version' => '1.0.0']]);
|
|
|
|
$this->manager()->installModule('aus-deltas');
|
|
|
|
$this->assertSame(['ALTER TABLE `aus` ADD COLUMN `one` int'], $this->rDb->statements);
|
|
$this->assertSame(['installed_version' => '1.1.0'], $this->recorded('aus-deltas'));
|
|
}
|
|
|
|
// ── An archive uploaded over an installed module ─────────────────
|
|
|
|
public function testAnArchiveUploadedOverAnInstalledModuleRunsItsDeltas(): void {
|
|
$rHash = str_repeat('ab12', 8);
|
|
$this->module($this->rModules . '/aus-up_ab12a', 'aus-up', '1.0.0', ['database.sql' => self::MASTER . ';'], [], $rHash);
|
|
$this->overrides(['aus-up' => ['installed_version' => '1.0.0', 'state' => 'disabled']]);
|
|
|
|
$this->upload($this->archive('aus-up', '1.1.0', [
|
|
'database.sql' => self::MASTER . ';',
|
|
'migrations/1.1.0.sql' => 'ALTER TABLE `aus` ADD COLUMN `one` int;',
|
|
], $rHash));
|
|
|
|
$this->assertSame(['ALTER TABLE `aus` ADD COLUMN `one` int', self::MASTER], $this->rDb->statements);
|
|
$this->assertSame('1.1.0', $this->recorded('aus-up')['installed_version']);
|
|
$this->assertSame('disabled', $this->recorded('aus-up')['state'], 'a module the admin switched off stays off');
|
|
$this->assertSame('1.1.0', $this->versionIn($this->rModules . '/aus-up_ab12a'));
|
|
$this->assertSame([], $this->backups(), 'the copy set aside is gone once the new one is in');
|
|
}
|
|
|
|
public function testAnUploadThatFailsToInstallPutsTheInstalledCopyBack(): void {
|
|
$rHash = str_repeat('cd34', 8);
|
|
$this->module($this->rModules . '/aus-fail_cd34c', 'aus-fail', '1.0.0', [], [], $rHash);
|
|
$this->overrides(['aus-fail' => ['installed_version' => '1.0.0', 'state' => 'disabled']]);
|
|
$this->rDb->failOn = 'BOOM';
|
|
|
|
try {
|
|
$this->upload($this->archive('aus-fail', '1.2.0', [
|
|
'migrations/1.1.0.sql' => 'ALTER TABLE `aus` ADD COLUMN `one` int;',
|
|
'migrations/1.2.0.sql' => 'BOOM;',
|
|
], $rHash));
|
|
$this->fail('The failing delta must stop the upload.');
|
|
} catch (RuntimeException $e) {
|
|
$this->assertStringContainsString('1.2.0.sql', $e->getMessage());
|
|
}
|
|
|
|
$this->assertSame([$this->rModules . '/aus-fail_cd34c'], glob($this->rModules . '/*'));
|
|
$this->assertSame('1.0.0', $this->versionIn($this->rModules . '/aus-fail_cd34c'));
|
|
$this->assertSame([], $this->backups());
|
|
// The recorded version is the one put back; the delta that did land is
|
|
// on record beside it, so uploading again resumes after it.
|
|
$this->assertSame(
|
|
['installed_version' => '1.0.0', 'schema_version' => '1.1.0', 'state' => 'disabled'],
|
|
$this->recorded('aus-fail')
|
|
);
|
|
}
|
|
|
|
public function testAnUploadWhoseInstallHookFailsDoesNotApplyItsDeltasAgainWhenRetried(): void {
|
|
$rHash = str_repeat('5e6f', 8);
|
|
$rFiles = [
|
|
'database.sql' => self::MASTER . ';',
|
|
'migrations/1.1.0.sql' => 'ALTER TABLE `aus` ADD COLUMN `one` int;',
|
|
];
|
|
$this->module($this->rModules . '/aus-hook_5e6f5', 'aus-hook', '1.0.0', ['database.sql' => self::MASTER . ';'], [], $rHash);
|
|
$this->overrides(['aus-hook' => ['installed_version' => '1.0.0']]);
|
|
|
|
try {
|
|
$this->upload($this->archive('aus-hook', '1.1.0', $rFiles + ['install-fails' => ''], $rHash));
|
|
$this->fail('The failing install() must stop the upload.');
|
|
} catch (RuntimeException $e) {
|
|
$this->assertStringContainsString('install() refused', $e->getMessage());
|
|
}
|
|
|
|
$this->assertSame('1.0.0', $this->versionIn($this->rModules . '/aus-hook_5e6f5'));
|
|
$this->assertSame(['installed_version' => '1.0.0', 'schema_version' => '1.1.0'], $this->recorded('aus-hook'));
|
|
|
|
$this->rDb->statements = [];
|
|
$this->upload($this->archive('aus-hook', '1.1.0', $rFiles, $rHash));
|
|
|
|
$this->assertSame([self::MASTER], $this->rDb->statements);
|
|
$this->assertSame('1.1.0', $this->versionIn($this->rModules . '/aus-hook_5e6f5'));
|
|
$this->assertSame(['installed_version' => '1.1.0'], $this->recorded('aus-hook'));
|
|
}
|
|
|
|
public function testAFailedUploadUnderANewDirectoryNameLeavesOnlyTheInstalledCopy(): void {
|
|
// An archive without a hash_id is given a new one, so a new directory.
|
|
$this->module($this->rModules . '/aus-new_ef56e', 'aus-new', '1.0.0', [], [], str_repeat('ef56', 8));
|
|
$this->overrides(['aus-new' => ['installed_version' => '1.0.0']]);
|
|
$this->rDb->failOn = 'BOOM';
|
|
|
|
try {
|
|
$this->upload($this->archive('aus-new', '1.1.0', ['migrations/1.1.0.sql' => 'BOOM;']));
|
|
$this->fail('The failing delta must stop the upload.');
|
|
} catch (RuntimeException $e) {
|
|
$this->assertStringContainsString('1.1.0.sql', $e->getMessage());
|
|
}
|
|
|
|
$this->assertSame([$this->rModules . '/aus-new_ef56e'], glob($this->rModules . '/*'));
|
|
$this->assertSame('1.0.0', $this->versionIn($this->rModules . '/aus-new_ef56e'));
|
|
$this->assertSame(['installed_version' => '1.0.0'], $this->recorded('aus-new'), 'and it loads again');
|
|
}
|
|
|
|
public function testAFirstUploadThatFailsStaysOnDiskAsFailed(): void {
|
|
$this->rDb->failOn = 'BOOM';
|
|
|
|
try {
|
|
$this->upload($this->archive('aus-once', '1.0.0', ['database.sql' => 'BOOM;'], str_repeat('0a1b', 8)));
|
|
$this->fail('The failing schema must stop the upload.');
|
|
} catch (RuntimeException $e) {
|
|
$this->assertStringContainsString('database.sql', $e->getMessage());
|
|
}
|
|
|
|
$this->assertSame('1.0.0', $this->versionIn($this->rModules . '/aus-once_0a1b0'));
|
|
$this->assertSame(['state' => 'failed'], $this->recorded('aus-once'));
|
|
}
|
|
|
|
// ── A store update, through the platform extension ───────────────
|
|
|
|
public function testAStoreUpdateRunsTheDeltasOfTheVersionItServes(): void {
|
|
$rHash = str_repeat('9f8e', 8);
|
|
$this->module($this->rModules . '/aus-store_9f8e9', 'aus-store', '1.0.0', ['database.sql' => self::MASTER . ';'], [], $rHash);
|
|
$this->overrides(['aus-store' => ['installed_version' => '1.0.0', 'source' => 'platform']]);
|
|
// What the store serves as 1.1.0, as the extension unpacks it.
|
|
$this->module($this->rWork . '/served/aus-store', 'aus-store', '1.1.0', [
|
|
'database.sql' => self::MASTER . ';',
|
|
'migrations/1.1.0.sql' => 'ALTER TABLE `aus` ADD COLUMN `one` int;',
|
|
], ['1.1.0'], $rHash);
|
|
|
|
$rResult = $this->storeInstall('aus-store', '1.1.0');
|
|
|
|
$this->assertSame('', $rResult['error']);
|
|
$this->assertSame(['ALTER TABLE `aus` ADD COLUMN `one` int', self::MASTER], $rResult['statements']);
|
|
$this->assertSame(['migration 1.1.0', 'install'], $rResult['ran']);
|
|
$this->assertSame('1.1.0', $this->recorded('aus-store')['installed_version']);
|
|
$this->assertSame('1.1.0', $this->versionIn($this->rModules . '/aus-store_9f8e9'));
|
|
}
|
|
|
|
public function testAStoreModuleIsNamedToTheStoreByItsHashIdAndRecordedByItsName(): void {
|
|
// The store serves it under its hash_id; here it is "aus-hashed".
|
|
$rHash = str_repeat('5d6e', 8);
|
|
$this->module($this->rWork . '/served/' . $rHash, 'aus-hashed', '1.0.0', ['database.sql' => self::MASTER . ';'], [], $rHash);
|
|
|
|
$rResult = $this->storeInstall($rHash, '1.0.0');
|
|
|
|
$this->assertSame('', $rResult['error']);
|
|
$this->assertSame('1.0.0', $this->versionIn($this->rModules . '/aus-hashed_5d6e5'));
|
|
$rData = require $this->rOverrides;
|
|
$this->assertSame('platform', $rData['aus-hashed']['source'] ?? null);
|
|
$this->assertArrayNotHasKey($rHash, $rData);
|
|
|
|
// The update finds the installed copy by its hash_id and replaces it.
|
|
$this->module($this->rWork . '/served/' . $rHash, 'aus-hashed', '1.1.0', ['database.sql' => self::MASTER . ';'], [], $rHash);
|
|
|
|
$rResult = $this->storeInstall($rHash, '1.1.0');
|
|
|
|
$this->assertSame('', $rResult['error']);
|
|
$this->assertSame('1.1.0', $this->versionIn($this->rModules . '/aus-hashed_5d6e5'));
|
|
$this->assertSame('1.1.0', $this->recorded('aus-hashed')['installed_version']);
|
|
$this->assertSame([], $this->backups());
|
|
}
|
|
|
|
// ── An update that fails and is rolled back ──────────────────────
|
|
|
|
public function testARolledBackStoreUpdateResumesAfterTheDeltasThatLanded(): void {
|
|
$rHash = str_repeat('7c6d', 8);
|
|
$this->module($this->rModules . '/aus-resume_7c6d7', 'aus-resume', '1.0.0', ['database.sql' => self::MASTER . ';'], [], $rHash);
|
|
$this->overrides(['aus-resume' => ['installed_version' => '1.0.0', 'source' => 'platform']]);
|
|
$rServed = [
|
|
'database.sql' => self::MASTER . ';',
|
|
'migrations/1.1.0.sql' => 'ALTER TABLE `aus` ADD COLUMN `one` int;',
|
|
'migrations/1.2.0.sql' => 'BOOM;',
|
|
];
|
|
$this->module($this->rWork . '/served/aus-resume', 'aus-resume', '1.2.0', $rServed, [], $rHash);
|
|
|
|
$rResult = $this->storeInstall('aus-resume', '1.2.0');
|
|
|
|
$this->assertStringContainsString('rolled back', $rResult['error']);
|
|
$this->assertSame('1.0.0', $this->versionIn($this->rModules . '/aus-resume_7c6d7'));
|
|
$this->assertSame(['installed_version' => '1.0.0', 'schema_version' => '1.1.0'], $this->recorded('aus-resume'));
|
|
|
|
// The store then serves a 1.2.0 whose delta applies.
|
|
$rServed['migrations/1.2.0.sql'] = 'ALTER TABLE `aus` ADD COLUMN `two` int;';
|
|
$this->module($this->rWork . '/served/aus-resume', 'aus-resume', '1.2.0', $rServed, [], $rHash);
|
|
|
|
$rResult = $this->storeInstall('aus-resume', '1.2.0');
|
|
|
|
$this->assertSame('', $rResult['error']);
|
|
$this->assertSame(['ALTER TABLE `aus` ADD COLUMN `two` int', self::MASTER], $rResult['statements']);
|
|
$this->assertSame(['installed_version' => '1.2.0'], $this->recorded('aus-resume'));
|
|
$this->assertSame('1.2.0', $this->versionIn($this->rModules . '/aus-resume_7c6d7'));
|
|
}
|
|
|
|
public function testAStoreUpdateThatFailsAfterItsDeltasDoesNotApplyThemAgainWhenRetried(): void {
|
|
$rHash = str_repeat('3b4c', 8);
|
|
$this->module($this->rModules . '/aus-twice_3b4c3', 'aus-twice', '1.0.0', ['database.sql' => self::MASTER . ';'], [], $rHash);
|
|
$this->overrides(['aus-twice' => ['installed_version' => '1.0.0', 'source' => 'platform']]);
|
|
$rServed = [
|
|
'database.sql' => self::MASTER . ';',
|
|
'migrations/1.1.0.sql' => 'ALTER TABLE `aus` ADD COLUMN `one` int;',
|
|
];
|
|
$this->module($this->rWork . '/served/aus-twice', 'aus-twice', '1.1.0', $rServed + ['install-fails' => ''], [], $rHash);
|
|
|
|
$rResult = $this->storeInstall('aus-twice', '1.1.0');
|
|
|
|
$this->assertStringContainsString('rolled back', $rResult['error']);
|
|
$this->assertSame(['ALTER TABLE `aus` ADD COLUMN `one` int', self::MASTER], $rResult['statements']);
|
|
$this->assertSame('1.0.0', $this->versionIn($this->rModules . '/aus-twice_3b4c3'));
|
|
// Recorded at the version put back, so the update is still offered.
|
|
$this->assertSame(['installed_version' => '1.0.0', 'schema_version' => '1.1.0'], $this->recorded('aus-twice'));
|
|
|
|
// The store then serves a 1.1.0 that installs.
|
|
unlink($this->rWork . '/served/aus-twice/install-fails');
|
|
|
|
$rResult = $this->storeInstall('aus-twice', '1.1.0');
|
|
|
|
$this->assertSame('', $rResult['error']);
|
|
$this->assertSame([self::MASTER], $rResult['statements']);
|
|
$this->assertSame(['installed_version' => '1.1.0'], $this->recorded('aus-twice'));
|
|
$this->assertSame('1.1.0', $this->versionIn($this->rModules . '/aus-twice_3b4c3'));
|
|
}
|
|
|
|
public function testFilesPutBackAreRecordedAtTheirVersionAndTheSchemaWhereItGot(): void {
|
|
// The step every rolled back update ends with, git and url ones too.
|
|
$rRestore = new ReflectionMethod(ModuleManager::class, 'restoreModuleBackup');
|
|
$rTarget = $this->rModules . '/aus-reached_1a2b3';
|
|
$rBackup = $this->rWork . '/.module_backups/aus-reached_old';
|
|
$this->module($rTarget, 'aus-reached', '1.2.0');
|
|
$this->module($rBackup, 'aus-reached', '1.0.0');
|
|
$this->overrides(['aus-reached' => ['installed_version' => '1.2.0']]);
|
|
|
|
$this->assertTrue($rRestore->invoke($this->manager(), 'aus-reached', $rTarget, $rBackup, '1.0.0'));
|
|
|
|
$this->assertSame('1.0.0', $this->versionIn($rTarget));
|
|
$this->assertSame(['installed_version' => '1.0.0', 'schema_version' => '1.2.0'], $this->recorded('aus-reached'));
|
|
}
|
|
|
|
// ── A process that already loaded the old version ────────────────
|
|
|
|
public function testFileDeltasDoNotDependOnWhichVersionOfTheClassIsLoaded(): void {
|
|
$rHash = str_repeat('8a9b', 8);
|
|
$rFiles = ['database.sql' => self::MASTER . ';'];
|
|
$this->module($this->rModules . '/aus-loaded_8a9b8', 'aus-loaded', '1.0.0', $rFiles, [], $rHash);
|
|
$this->overrides(['aus-loaded' => ['installed_version' => '1.0.0']]);
|
|
// An admin request loads every enabled module before it handles the upload.
|
|
(new ModuleLoader())->loadAll($this->rModules);
|
|
|
|
$this->upload($this->archive('aus-loaded', '1.1.0', $rFiles + [
|
|
'migrations/1.1.0.sql' => 'ALTER TABLE `aus` ADD COLUMN `one` int;',
|
|
], $rHash, ['1.1.0']));
|
|
|
|
$this->assertSame(['ALTER TABLE `aus` ADD COLUMN `one` int', self::MASTER], $this->rDb->statements);
|
|
$this->assertSame('1.1.0', $this->recorded('aus-loaded')['installed_version']);
|
|
// Known limit: PHP keeps the class it loaded first, so install() is the
|
|
// old version's and the new version's getMigrations() steps are not seen.
|
|
$this->assertSame(['install'], AuditSchemaLog::$ran);
|
|
}
|
|
|
|
// ── Fixtures ─────────────────────────────────────────────────────
|
|
|
|
private function manager(): ModuleManager {
|
|
return new ModuleManager($this->rModules, $this->rOverrides, ServiceContainer::getInstance());
|
|
}
|
|
|
|
/** uploadAndInstall(), up to the hand-over to the load balancers. */
|
|
private function upload(string $rArchive): void {
|
|
try {
|
|
$this->manager()->uploadAndInstall($rArchive);
|
|
} catch (Error $e) {
|
|
// The last step asks xcvm_core whether this is a load balancer, and
|
|
// the extension is absent here; placing and installing are done by then.
|
|
$this->assertStringContainsString('XC_VM', $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* downloadFromPlatform() in a child PHP whose xcvm_core stand-in unpacks
|
|
* `served/{slug}` into the modules directory, as the extension does.
|
|
*
|
|
* @return array{error: string, statements: list<string>, ran: list<string>}
|
|
*/
|
|
private function storeInstall(string $rSlug, string $rVersion): array {
|
|
$rPrepend = $this->rWork . '/prepend.php';
|
|
file_put_contents($rPrepend, <<<'PHP'
|
|
<?php
|
|
final class XC_VM {
|
|
public static function config_server(): array {
|
|
return ['server_id' => 1, 'is_lb' => 0];
|
|
}
|
|
|
|
public static function panel_register(string $rKey): array {
|
|
return ['ok' => true];
|
|
}
|
|
|
|
public static function module_install(string $rSlug, string $rVersion, string $rKey): array {
|
|
$rPath = getenv('AUS_WORK') . '/modules/' . $rSlug;
|
|
exec('cp -r ' . escapeshellarg(getenv('AUS_WORK') . '/served/' . $rSlug) . ' ' . escapeshellarg($rPath));
|
|
return ['ok' => true, 'module' => $rSlug, 'version' => getenv('AUS_VERSION'), 'path' => $rPath];
|
|
}
|
|
}
|
|
PHP);
|
|
$rScript = $this->rWork . '/child.php';
|
|
file_put_contents($rScript, <<<'PHP'
|
|
<?php
|
|
use XcVm\Core\Container\ServiceContainer;
|
|
use XcVm\Core\Module\ModuleManager;
|
|
|
|
require getenv('AUS_TESTS') . '/bootstrap.php';
|
|
|
|
class AuditSchemaLog {
|
|
public static array $ran = [];
|
|
}
|
|
$rDb = new class {
|
|
public array $statements = [];
|
|
|
|
public function query(string $rSql): bool {
|
|
$this->statements[] = rtrim($rSql, ';');
|
|
return strpos($rSql, 'BOOM') === false;
|
|
}
|
|
};
|
|
ServiceContainer::getInstance()->set('db', $rDb);
|
|
$rWork = getenv('AUS_WORK');
|
|
$rError = '';
|
|
try {
|
|
(new ModuleManager($rWork . '/modules', $rWork . '/modules.php', ServiceContainer::getInstance()))
|
|
->downloadFromPlatform($argv[1], '', 'key');
|
|
} catch (\Throwable $e) {
|
|
$rError = get_class($e) . ': ' . $e->getMessage();
|
|
}
|
|
echo json_encode(['error' => $rError, 'statements' => $rDb->statements, 'ran' => AuditSchemaLog::$ran]);
|
|
PHP);
|
|
$rProc = proc_open(
|
|
[...xcvm_test_child_php(), '-d', 'auto_prepend_file=' . $rPrepend, $rScript, $rSlug],
|
|
[0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
|
|
$rPipes,
|
|
$this->rWork,
|
|
['AUS_WORK' => $this->rWork, 'AUS_VERSION' => $rVersion, 'AUS_TESTS' => dirname(__DIR__), 'PATH' => (string) getenv('PATH')]
|
|
);
|
|
$this->assertIsResource($rProc);
|
|
$rOut = (string) stream_get_contents($rPipes[1]);
|
|
$rErr = (string) stream_get_contents($rPipes[2]);
|
|
fclose($rPipes[1]);
|
|
fclose($rPipes[2]);
|
|
proc_close($rProc);
|
|
$rResult = json_decode($rOut, true);
|
|
$this->assertIsArray($rResult, $rOut . $rErr);
|
|
return $rResult;
|
|
}
|
|
|
|
private function overrides(array $rOverrides): void {
|
|
file_put_contents($this->rOverrides, "<?php\n\nreturn " . var_export($rOverrides, true) . ";\n");
|
|
}
|
|
|
|
/** The module's entry in config/modules.php. */
|
|
private function recorded(string $rName): array {
|
|
$rData = is_file($this->rOverrides) ? require $this->rOverrides : [];
|
|
$rEntry = $rData[$rName] ?? [];
|
|
unset($rEntry['source']);
|
|
ksort($rEntry);
|
|
return $rEntry;
|
|
}
|
|
|
|
private function versionIn(string $rDir): ?string {
|
|
$rMeta = json_decode((string) @file_get_contents($rDir . '/module.json'), true);
|
|
return is_array($rMeta) ? ($rMeta['version'] ?? null) : null;
|
|
}
|
|
|
|
/** @return string[] What is left in the area installed copies are set aside in. */
|
|
private function backups(): array {
|
|
return glob($this->rWork . '/.module_backups/*') ?: [];
|
|
}
|
|
|
|
/**
|
|
* Write a module into $rDir.
|
|
*
|
|
* @param array<string, string> $rFiles Schema files, by path inside the module; an
|
|
* `install-fails` file makes install() throw.
|
|
* @param string[] $rCallables Versions that have a programmatic migration.
|
|
*/
|
|
private function module(string $rDir, string $rName, string $rVersion, array $rFiles = [], array $rCallables = [], ?string $rHashId = null): void {
|
|
$rPascal = implode('', array_map('ucfirst', explode('-', $rName)));
|
|
$rSteps = '';
|
|
foreach ($rCallables as $rStep) {
|
|
$rSteps .= "'{$rStep}' => function () { \\AuditSchemaLog::\$ran[] = 'migration {$rStep}'; }, ";
|
|
}
|
|
$rFiles['module.json'] = (string) json_encode(array_filter([
|
|
'name' => $rName,
|
|
'hash_id' => $rHashId,
|
|
'version' => $rVersion,
|
|
'requires_core' => '>=2.0',
|
|
'environment' => 'main',
|
|
'dependencies' => [],
|
|
], static fn($rValue) => $rValue !== null));
|
|
$rFiles[$rPascal . 'Module.php'] = "<?php\nnamespace XcVm\\Module\\{$rPascal};\n"
|
|
. "use XcVm\\Core\\Module\\BaseModule;\n"
|
|
. "class {$rPascal}Module extends BaseModule {\n"
|
|
. "\tpublic function getName(): string { return '{$rName}'; }\n"
|
|
. "\tpublic function getVersion(): string { return '{$rVersion}'; }\n"
|
|
. "\tpublic function install(): void {\n"
|
|
. "\t\tif (is_file(__DIR__ . '/install-fails')) { throw new \\RuntimeException('install() refused'); }\n"
|
|
. "\t\t\\AuditSchemaLog::\$ran[] = 'install';\n"
|
|
. "\t}\n"
|
|
. "\tpublic function getMigrations(): array { return [{$rSteps}]; }\n"
|
|
. "}\n";
|
|
foreach ($rFiles as $rPath => $rContent) {
|
|
@mkdir(dirname($rDir . '/' . $rPath), 0775, true);
|
|
file_put_contents($rDir . '/' . $rPath, $rContent);
|
|
}
|
|
}
|
|
|
|
/** A .tar holding the module under a `{name}/` prefix; returns its path. */
|
|
private function archive(string $rName, string $rVersion, array $rFiles = [], ?string $rHashId = null, array $rCallables = []): string {
|
|
$rStage = $this->rWork . '/archive-' . $rName . '-' . $rVersion . '-' . bin2hex(random_bytes(3));
|
|
$this->module($rStage . '/' . $rName, $rName, $rVersion, $rFiles, $rCallables, $rHashId);
|
|
$rPath = $rStage . '.tar';
|
|
(new PharData($rPath))->buildFromDirectory($rStage);
|
|
return $rPath;
|
|
}
|
|
}
|