diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index 0a48a595d..f95b322cf 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -286,6 +286,19 @@ jobs: CI_STRICT_TESTS: '1' run: bash scripts/verify-schema.sh + # Exercise the plugin ZIP-update path against a real DB. The synthetic + # test covers install→update on a disposable plugin; the all-bundled test + # updates EVERY registered bundled plugin in place and restores it, so a + # broken update path surfaces here before it reaches an operator's admin + # UI. Runs after the schema gate so that gate sees pristine plugin data; + # the all-bundled test restores every directory and plugins-row version. + - name: PHP plugin ZIP-update integration tests + env: + CI_STRICT_TESTS: '1' + run: | + php tests/plugin-zip-update.integration.php + php tests/plugin-zip-update-all-bundled.integration.php + - name: Shell test — bin/setup-permissions.sh run: bash tests/setup-permissions.test.sh diff --git a/.gitignore b/.gitignore index c90055a0c..9b13bb7c7 100644 --- a/.gitignore +++ b/.gitignore @@ -420,6 +420,7 @@ tests/* !tests/*.spec.js !tests/*.config.js !tests/*.unit.php +!tests/*.integration.php !tests/*.test.sh !tests/ci-playwright-policy.json !tests/seeds/ diff --git a/app/Support/PluginManager.php b/app/Support/PluginManager.php index 08199bfe4..29c14feba 100644 --- a/app/Support/PluginManager.php +++ b/app/Support/PluginManager.php @@ -738,7 +738,7 @@ public function installFromZip(string $zipPath): array $pluginJsonContent = $zip->getFromName($pluginJsonPath); $pluginMeta = json_decode($pluginJsonContent, true); - if (!$pluginMeta) { + if (!is_array($pluginMeta)) { $zip->close(); return ['success' => false, 'message' => __('File plugin.json non valido.'), 'plugin_id' => null]; } @@ -746,7 +746,7 @@ public function installFromZip(string $zipPath): array // Validate required fields $requiredFields = ['name', 'display_name', 'version', 'main_file']; foreach ($requiredFields as $field) { - if (empty($pluginMeta[$field])) { + if (!isset($pluginMeta[$field]) || !is_string($pluginMeta[$field]) || trim($pluginMeta[$field]) === '') { $zip->close(); return ['success' => false, 'message' => __('Campo obbligatorio mancante: %s', $field), 'plugin_id' => null]; } @@ -757,12 +757,10 @@ public function installFromZip(string $zipPath): array return ['success' => false, 'message' => __('Nome plugin non valido. Usa solo lettere, numeri, trattini o underscore.'), 'plugin_id' => null]; } - // Check if plugin already exists + // An uploaded package with an existing name is an in-place update. Its + // database ID deliberately remains stable, so plugin settings, data and + // hook rows continue to point to the same plugin after the update. $existingPlugin = $this->getPluginByName($pluginMeta['name']); - if ($existingPlugin) { - $zip->close(); - return ['success' => false, 'message' => __('Plugin già installato.'), 'plugin_id' => null]; - } // Check PHP version compatibility if (!empty($pluginMeta['requires_php'])) { @@ -782,27 +780,37 @@ public function installFromZip(string $zipPath): array return ['success' => false, 'message' => $appCompatibilityError, 'plugin_id' => null]; } - // Extract plugin to storage/plugins directory + // Extract into a sibling staging directory first. Never touch the + // installed copy until the whole archive has been validated, so a bad + // update cannot leave an otherwise working plugin half-extracted. $pluginsBaseDir = realpath($this->pluginsDir) ?: $this->pluginsDir; - $pluginPath = $pluginsBaseDir . '/' . $pluginMeta['name']; + $targetDirectory = $existingPlugin !== null + ? (string) ($existingPlugin['path'] ?? '') + : (string) $pluginMeta['name']; - if (is_dir($pluginPath)) { + if (!$this->isSafePluginDirectoryName($targetDirectory)) { + $zip->close(); + return ['success' => false, 'message' => __('Percorso di installazione del plugin non valido.'), 'plugin_id' => null]; + } + + $pluginPath = $pluginsBaseDir . '/' . $targetDirectory; + if ($existingPlugin === null && is_dir($pluginPath)) { $zip->close(); return ['success' => false, 'message' => __('Directory plugin già esistente.'), 'plugin_id' => null]; } - if (!mkdir($pluginPath, 0755, true)) { + $stagingPath = $this->createPluginStagingDirectory($pluginsBaseDir, $targetDirectory); + if ($stagingPath === null) { $zip->close(); - return ['success' => false, 'message' => __('Impossibile creare la directory del plugin.'), 'plugin_id' => null]; + return ['success' => false, 'message' => __('Impossibile creare la directory temporanea del plugin.'), 'plugin_id' => null]; } - $pluginRealPath = realpath($pluginPath); + $pluginRealPath = realpath($stagingPath); if ($pluginRealPath === false || strpos($pluginRealPath, rtrim($pluginsBaseDir, DIRECTORY_SEPARATOR)) !== 0) { $zip->close(); - $this->deleteDirectory($pluginPath); + $this->deleteDirectory($stagingPath); return ['success' => false, 'message' => __('Percorso di installazione del plugin non valido.'), 'plugin_id' => null]; } - $pluginPath = $pluginRealPath; $extractedFiles = false; $pluginRootPrefix = $pluginRootDir ? rtrim($pluginRootDir, '/') . '/' : null; @@ -832,14 +840,14 @@ public function installFromZip(string $zipPath): array if ($targetPath === null) { $zip->close(); - $this->deleteDirectory($pluginPath); + $this->deleteDirectory($stagingPath); return ['success' => false, 'message' => __('Il pacchetto contiene percorsi non validi.'), 'plugin_id' => null]; } if (str_ends_with($filename, '/')) { if (!is_dir($targetPath) && !mkdir($targetPath, 0755, true)) { $zip->close(); - $this->deleteDirectory($pluginPath); + $this->deleteDirectory($stagingPath); return ['success' => false, 'message' => __('Impossibile creare la struttura del plugin.'), 'plugin_id' => null]; } continue; @@ -848,14 +856,14 @@ public function installFromZip(string $zipPath): array $dir = dirname($targetPath); if (!is_dir($dir) && !mkdir($dir, 0755, true)) { $zip->close(); - $this->deleteDirectory($pluginPath); + $this->deleteDirectory($stagingPath); return ['success' => false, 'message' => __('Impossibile creare la struttura del plugin.'), 'plugin_id' => null]; } $content = $zip->getFromIndex($i); if ($content === false || file_put_contents($targetPath, $content) === false) { $zip->close(); - $this->deleteDirectory($pluginPath); + $this->deleteDirectory($stagingPath); return ['success' => false, 'message' => __('Errore durante l\'estrazione del plugin.'), 'plugin_id' => null]; } @@ -865,17 +873,36 @@ public function installFromZip(string $zipPath): array $zip->close(); if (!$extractedFiles) { - $this->deleteDirectory($pluginPath); + $this->deleteDirectory($stagingPath); return ['success' => false, 'message' => __('Il pacchetto non contiene file validi.'), 'plugin_id' => null]; } // Verify main file exists - $mainFilePath = $pluginPath . '/' . $pluginMeta['main_file']; + if (!$this->isSafePluginFilePath((string) $pluginMeta['main_file'])) { + $this->deleteDirectory($stagingPath); + return ['success' => false, 'message' => __('File principale del plugin non valido.'), 'plugin_id' => null]; + } + $mainFilePath = $pluginRealPath . '/' . $pluginMeta['main_file']; if (!file_exists($mainFilePath)) { - $this->deleteDirectory($pluginPath); + $this->deleteDirectory($stagingPath); return ['success' => false, 'message' => __('File principale del plugin non trovato.'), 'plugin_id' => null]; } + if ($existingPlugin !== null) { + return $this->updatePluginFromStaging( + $existingPlugin, + $pluginMeta, + $stagingPath, + $pluginsBaseDir + ); + } + + if (!rename($pluginRealPath, $pluginPath)) { + $this->deleteDirectory($stagingPath); + return ['success' => false, 'message' => __('Impossibile finalizzare l\'installazione del plugin.'), 'plugin_id' => null]; + } + $pluginPath = realpath($pluginPath) ?: $pluginPath; + // Insert plugin into database $stmt = $this->db->prepare(" INSERT INTO plugins ( @@ -890,7 +917,7 @@ public function installFromZip(string $zipPath): array // row exists. Clean up before returning. $this->deleteDirectory($pluginPath); SecureLogger::error('[PluginManager] Failed to prepare plugin INSERT', [ - 'plugin' => $pluginMeta['name'] ?? 'unknown', + 'plugin' => $pluginMeta['name'], 'db_error' => $this->db->error, ]); return [ @@ -1228,6 +1255,174 @@ private function getPluginClassName(string $pluginName): string return $className . 'Plugin'; } + /** + * Replace the on-disk package and its metadata without changing the plugin + * identity. Settings, plugin_data, logs and hooks all refer to the existing + * ID through foreign keys and are therefore intentionally left untouched. + * + * Filesystem changes are rollback-safe: the old package is kept as a sibling + * backup until the metadata update succeeds. + * + * @param array $existingPlugin + * @param array $pluginMeta + * @return array{success:bool,message:string,plugin_id:int|null,updated?:bool} + */ + private function updatePluginFromStaging( + array $existingPlugin, + array $pluginMeta, + string $stagingPath, + string $pluginsBaseDir + ): array { + $pluginId = (int) ($existingPlugin['id'] ?? 0); + $directory = (string) ($existingPlugin['path'] ?? ''); + if ($pluginId <= 0 || !$this->isSafePluginDirectoryName($directory)) { + $this->deleteDirectory($stagingPath); + return ['success' => false, 'message' => __('Plugin installato non valido.'), 'plugin_id' => null]; + } + + try { + $metadata = json_encode($pluginMeta['metadata'] ?? [], JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $this->deleteDirectory($stagingPath); + return ['success' => false, 'message' => __('Metadati del plugin non validi.'), 'plugin_id' => null]; + } + + $pluginPath = rtrim($pluginsBaseDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $directory; + try { + $backupPath = rtrim($pluginsBaseDir, DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR . '.' . $directory . '.backup-' . bin2hex(random_bytes(8)); + } catch (\Throwable $e) { + $this->deleteDirectory($stagingPath); + return ['success' => false, 'message' => __('Impossibile preparare l\'aggiornamento del plugin.'), 'plugin_id' => null]; + } + $hasBackup = false; + $newPackageInstalled = false; + + try { + if (file_exists($pluginPath) && !is_dir($pluginPath)) { + throw new \RuntimeException('Il percorso del plugin esistente non è una directory.'); + } + + if (is_dir($pluginPath)) { + if (!rename($pluginPath, $backupPath)) { + throw new \RuntimeException('Impossibile preparare il backup del plugin esistente.'); + } + $hasBackup = true; + } + + if (!rename($stagingPath, $pluginPath)) { + throw new \RuntimeException('Impossibile sostituire i file del plugin.'); + } + $newPackageInstalled = true; + + $displayName = (string) $pluginMeta['display_name']; + $description = (string) ($pluginMeta['description'] ?? ''); + $version = (string) $pluginMeta['version']; + $author = (string) ($pluginMeta['author'] ?? ''); + $authorUrl = (string) ($pluginMeta['author_url'] ?? ''); + $pluginUrl = (string) ($pluginMeta['plugin_url'] ?? ''); + $mainFile = (string) $pluginMeta['main_file']; + $requiresPhp = (string) ($pluginMeta['requires_php'] ?? ''); + $requiresApp = (string) ($pluginMeta['requires_app'] ?? ''); + + $stmt = $this->db->prepare( + 'UPDATE plugins SET display_name = ?, description = ?, version = ?, author = ?, author_url = ?, ' + . 'plugin_url = ?, main_file = ?, requires_php = ?, requires_app = ?, metadata = ? WHERE id = ?' + ); + if ($stmt === false) { + throw new \RuntimeException('Impossibile aggiornare i metadati del plugin.'); + } + $stmt->bind_param( + 'ssssssssssi', + $displayName, + $description, + $version, + $author, + $authorUrl, + $pluginUrl, + $mainFile, + $requiresPhp, + $requiresApp, + $metadata, + $pluginId + ); + $updated = $stmt->execute(); + $stmt->close(); + if (!$updated) { + throw new \RuntimeException('Impossibile salvare i metadati aggiornati del plugin.'); + } + + if ($hasBackup && !$this->deleteDirectory($backupPath)) { + SecureLogger::warning('[PluginManager] Updated plugin backup could not be removed', [ + 'plugin' => $pluginMeta['name'], + 'path' => $backupPath, + ]); + } + + self::clearPluginCache(); + SecureLogger::info("[PluginManager] Plugin updated successfully: {$pluginMeta['name']} (ID: $pluginId)"); + return [ + 'success' => true, + 'message' => __('Plugin aggiornato con successo.'), + 'plugin_id' => $pluginId, + 'updated' => true, + ]; + } catch (\Throwable $e) { + if ($newPackageInstalled && is_dir($pluginPath)) { + $this->deleteDirectory($pluginPath); + } + if ($hasBackup && is_dir($backupPath) && !rename($backupPath, $pluginPath)) { + SecureLogger::error('[PluginManager] Failed to restore plugin after update rollback', [ + 'plugin' => $pluginMeta['name'], + 'path' => $backupPath, + ]); + } + if (is_dir($stagingPath)) { + $this->deleteDirectory($stagingPath); + } + SecureLogger::error('[PluginManager] Plugin update failed', [ + 'plugin' => $pluginMeta['name'], + 'error' => $e->getMessage(), + ]); + return [ + 'success' => false, + 'message' => __('Errore durante l\'aggiornamento del plugin: %s', $e->getMessage()), + 'plugin_id' => null, + ]; + } + } + + private function createPluginStagingDirectory(string $pluginsBaseDir, string $directory): ?string + { + try { + $path = rtrim($pluginsBaseDir, DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR . '.' . $directory . '.staging-' . bin2hex(random_bytes(8)); + } catch (\Throwable $e) { + return null; + } + + return mkdir($path, 0755, true) ? $path : null; + } + + private function isSafePluginDirectoryName(string $directory): bool + { + return (bool) preg_match('/^[A-Za-z0-9_-]+$/D', $directory); + } + + private function isSafePluginFilePath(string $path): bool + { + $path = str_replace('\\', '/', $path); + if ($path === '' || str_contains($path, "\0") || preg_match('#^(?:[A-Za-z]:)?/#', $path)) { + return false; + } + foreach (explode('/', $path) as $segment) { + if ($segment === '' || $segment === '.' || $segment === '..') { + return false; + } + } + return true; + } + /** * Resolve a ZIP entry path inside the plugin directory and prevent traversal */ diff --git a/tests/plugin-manager.unit.php b/tests/plugin-manager.unit.php index d0251f15b..5cf44a8c3 100644 --- a/tests/plugin-manager.unit.php +++ b/tests/plugin-manager.unit.php @@ -36,6 +36,16 @@ $hasWarn = $source !== false && str_contains($source, 'Schema/hook self-heal skipped'); $check($hasWarn, 'same-version failure is non-fatal (warning, no rethrow)'); +echo "\nPlugin ZIP update lifecycle:\n"; +$check($source !== false && str_contains($source, 'updatePluginFromStaging('), 'existing plugin ZIPs use the update path'); +$check($source !== false && str_contains($source, 'createPluginStagingDirectory('), 'ZIPs are extracted to a staging directory first'); +$check($source !== false && str_contains($source, "rename(\$pluginPath, \$backupPath)"), 'existing package is backed up before replacement'); +$check($source !== false && str_contains($source, "rename(\$stagingPath, \$pluginPath)"), 'staging package is atomically promoted'); +$check($source !== false && str_contains($source, 'Failed to restore plugin after update rollback'), 'failed update restores the prior package'); +$check($source !== false && str_contains($source, 'UPDATE plugins SET display_name'), 'update persists new manifest metadata'); +$check($source !== false && str_contains($source, "'updated' => true"), 'update response identifies a successful update'); +$check($source !== false && str_contains($source, 'isSafePluginFilePath'), 'manifest main_file is validated against traversal'); + echo "\n================================\n"; echo "Passed: $passed Failed: $failed\n"; exit($failed > 0 ? 1 : 0); diff --git a/tests/plugin-package-contract.unit.php b/tests/plugin-package-contract.unit.php new file mode 100644 index 000000000..dd8b1bc8f --- /dev/null +++ b/tests/plugin-package-contract.unit.php @@ -0,0 +1,72 @@ + 0 ? 1 : 0); diff --git a/tests/plugin-zip-update-all-bundled.integration.php b/tests/plugin-zip-update-all-bundled.integration.php new file mode 100644 index 000000000..ddc8e0e37 --- /dev/null +++ b/tests/plugin-zip-update-all-bundled.integration.php @@ -0,0 +1,213 @@ += 2 && ($value[0] === '"' || $value[0] === "'") && $value[-1] === $value[0]) { + $value = substr($value, 1, -1); + } + $values[trim($key)] = $value; + } + return $values; +} + +function pzua_rmdir(string $directory): void +{ + if (!is_dir($directory)) { + return; + } + foreach (array_diff(scandir($directory) ?: [], ['.', '..']) as $entry) { + $path = $directory . DIRECTORY_SEPARATOR . $entry; + is_dir($path) ? pzua_rmdir($path) : @unlink($path); + } + @rmdir($directory); +} + +function pzua_copydir(string $src, string $dst): void +{ + @mkdir($dst, 0775, true); + foreach (array_diff(scandir($src) ?: [], ['.', '..']) as $entry) { + $s = $src . DIRECTORY_SEPARATOR . $entry; + $d = $dst . DIRECTORY_SEPARATOR . $entry; + is_dir($s) ? pzua_copydir($s, $d) : @copy($s, $d); + } +} + +/** Build a ZIP of every file under $pluginDir, placed under "$slug/", bumping only the manifest version. */ +function pzua_zip_plugin(string $pluginDir, string $slug, string $bumpedVersion): string +{ + $zipPath = tempnam(sys_get_temp_dir(), 'pinakes-plugin-all-'); + if ($zipPath === false) { + throw new RuntimeException('Unable to create temporary ZIP path.'); + } + $zip = new ZipArchive(); + if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException('Unable to create ZIP for ' . $slug); + } + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($pluginDir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + /** @var SplFileInfo $item */ + foreach ($iterator as $item) { + $relative = substr($item->getPathname(), strlen($pluginDir) + 1); + $relative = str_replace(DIRECTORY_SEPARATOR, '/', $relative); + $entry = $slug . '/' . $relative; + if ($item->isDir()) { + $zip->addEmptyDir($entry); + continue; + } + if ($relative === 'plugin.json') { + $meta = json_decode((string) file_get_contents($item->getPathname()), true); + if (is_array($meta)) { + $meta['version'] = $bumpedVersion; + $zip->addFromString($entry, (string) json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + continue; + } + } + $zip->addFile($item->getPathname(), $entry); + } + $zip->close(); + return $zipPath; +} + +$env = pzua_env(__DIR__ . '/../.env'); +$socket = getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? ''); +$user = getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''); +$password = getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')); +$database = getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''); + +mysqli_report(MYSQLI_REPORT_OFF); +$db = (is_string($socket) && $socket !== '' && file_exists($socket)) + ? @new mysqli(null, $user, $password, $database, 0, $socket) + : @new mysqli($env['DB_HOST'] ?? '127.0.0.1', $user, $password, $database, (int) ($env['DB_PORT'] ?? 3306)); +if ($db->connect_errno !== 0) { + echo "SKIP: database not reachable\n"; + exit(0); +} +if (!class_exists(ZipArchive::class)) { + echo "SKIP: ZipArchive extension unavailable\n"; + exit(0); +} + +$root = dirname(__DIR__); +$pluginsDir = $root . '/storage/plugins'; +$pluginDirs = glob($pluginsDir . '/*', GLOB_ONLYDIR) ?: []; +sort($pluginDirs); + +$manager = new \App\Support\PluginManager($db, new \App\Support\HookManager($db)); +// Ensure every bundled plugin owns a plugins row so installFromZip takes the +// in-place UPDATE branch (a missing row would make it a fresh install instead). +$manager->autoRegisterBundledPlugins(); + +$passed = 0; +$failed = 0; +$check = static function (bool $ok, string $label) use (&$passed, &$failed): void { + if ($ok) { $passed++; echo " OK {$label}\n"; } + else { $failed++; echo " FAIL {$label}\n"; } +}; + +echo "ZIP update contract for every installed bundled plugin (" . count($pluginDirs) . " found):\n"; + +foreach ($pluginDirs as $pluginDir) { + $slug = basename($pluginDir); + $manifest = json_decode((string) @file_get_contents($pluginDir . '/plugin.json'), true); + if (!is_array($manifest) || !isset($manifest['name'])) { + $check(false, "{$slug}: has a readable plugin.json"); + continue; + } + $name = (string) $manifest['name']; + $row = $db->query("SELECT id, version FROM plugins WHERE name = '" . $db->real_escape_string($name) . "' LIMIT 1"); + $before = $row instanceof mysqli_result ? $row->fetch_assoc() : null; + if (!is_array($before)) { + $check(false, "{$slug}: is registered in the plugins table"); + continue; + } + $pluginId = (int) $before['id']; + $originalVersion = (string) $before['version']; + $bumped = ((string) ($manifest['version'] ?? '0.0.0')) . '-ziptest'; + + $backupDir = $pluginsDir . '/.zipupdate-backup-' . $slug; + pzua_rmdir($backupDir); + pzua_copydir($pluginDir, $backupDir); + $zipPath = null; + + try { + $zipPath = pzua_zip_plugin($pluginDir, $slug, $bumped); + $result = $manager->installFromZip($zipPath); + + $ok = ($result['success'] ?? false) === true + && ($result['updated'] ?? false) === true + && (int) ($result['plugin_id'] ?? 0) === $pluginId; + + $after = $db->query("SELECT version FROM plugins WHERE id = {$pluginId} LIMIT 1"); + $afterVersion = $after instanceof mysqli_result ? (string) ($after->fetch_row()[0] ?? '') : ''; + + $mainFile = (string) ($manifest['main_file'] ?? ''); + $filesPromoted = is_file($pluginDir . '/plugin.json') + && ($mainFile === '' || is_file($pluginDir . '/' . $mainFile)); + + $check( + $ok && $afterVersion === $bumped && $filesPromoted, + "{$slug}: ZIP update keeps id {$pluginId}, promotes files, persists version" + . ($ok ? '' : ' [msg: ' . ($result['message'] ?? 'unknown') . ']') + ); + } catch (\Throwable $e) { + $check(false, "{$slug}: ZIP update threw — " . $e->getMessage()); + } finally { + // Restore the plugin directory byte-for-byte and its plugins-row version. + pzua_rmdir($pluginDir); + pzua_copydir($backupDir, $pluginDir); + pzua_rmdir($backupDir); + $restore = $db->prepare('UPDATE plugins SET version = ? WHERE id = ?'); + if ($restore instanceof mysqli_stmt) { + $restore->bind_param('si', $originalVersion, $pluginId); + $restore->execute(); + $restore->close(); + } + if (is_string($zipPath)) { + @unlink($zipPath); + } + // Clean any stray staging/backup directories the update may have left. + foreach (glob($pluginsDir . '/.' . $slug . '.backup-*') ?: [] as $stray) { + pzua_rmdir($stray); + } + foreach (glob($pluginsDir . '/.' . $slug . '.staging-*') ?: [] as $stray) { + pzua_rmdir($stray); + } + } +} + +echo "\n{$passed} passed, {$failed} failed\n"; +$db->close(); +exit($failed === 0 ? 0 : 1); diff --git a/tests/plugin-zip-update.integration.php b/tests/plugin-zip-update.integration.php new file mode 100644 index 000000000..23e80d1d8 --- /dev/null +++ b/tests/plugin-zip-update.integration.php @@ -0,0 +1,157 @@ += 2 && ($value[0] === '"' || $value[0] === "'") && $value[-1] === $value[0]) { + $value = substr($value, 1, -1); + } + $values[trim($key)] = $value; + } + return $values; +} + +function pzu_delete_directory(string $directory): void +{ + if (!is_dir($directory)) { + return; + } + foreach (array_diff(scandir($directory) ?: [], ['.', '..']) as $entry) { + $path = $directory . DIRECTORY_SEPARATOR . $entry; + if (is_dir($path)) { + pzu_delete_directory($path); + } else { + @unlink($path); + } + } + @rmdir($directory); +} + +/** @return string ZIP path */ +function pzu_create_zip(string $slug, string $className, string $version, string $displayName): string +{ + $zipPath = tempnam(sys_get_temp_dir(), 'pinakes-plugin-update-'); + if ($zipPath === false) { + throw new RuntimeException('Unable to create temporary ZIP path.'); + } + $zip = new ZipArchive(); + if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException('Unable to create test ZIP.'); + } + $manifest = json_encode([ + 'name' => $slug, + 'display_name' => $displayName, + 'version' => $version, + 'main_file' => 'wrapper.php', + 'requires_php' => '8.2', + 'metadata' => ['test_package' => true], + ], JSON_THROW_ON_ERROR); + $wrapper = "addFromString($slug . '/plugin.json', $manifest); + $zip->addFromString($slug . '/wrapper.php', $wrapper); + $zip->close(); + return $zipPath; +} + +$env = pzu_env(__DIR__ . '/../.env'); +$socket = getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? ''); +$user = getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''); +$password = getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')); +$database = getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''); + +mysqli_report(MYSQLI_REPORT_OFF); +$db = (is_string($socket) && $socket !== '' && file_exists($socket)) + ? @new mysqli(null, $user, $password, $database, 0, $socket) + : @new mysqli($env['DB_HOST'] ?? '127.0.0.1', $user, $password, $database, (int) ($env['DB_PORT'] ?? 3306)); +if ($db->connect_errno !== 0) { + echo "SKIP: database not reachable\n"; + exit(0); +} +if (!class_exists(ZipArchive::class)) { + echo "SKIP: ZipArchive extension unavailable\n"; + exit(0); +} + +$suffix = bin2hex(random_bytes(6)); +$slug = 'plugin-zip-update-' . $suffix; +$className = 'PluginZipUpdate' . ucfirst($suffix) . 'Plugin'; +$pluginsDir = dirname(__DIR__) . '/storage/plugins'; +$pluginDir = $pluginsDir . '/' . $slug; +$zipV1 = null; +$zipV2 = null; +$pluginId = 0; + +try { + $manager = new \App\Support\PluginManager($db, new \App\Support\HookManager($db)); + $zipV1 = pzu_create_zip($slug, $className, '1.0.0', 'Disposable plugin v1'); + $firstInstall = $manager->installFromZip($zipV1); + if (($firstInstall['success'] ?? false) !== true) { + throw new RuntimeException('Initial install failed: ' . ($firstInstall['message'] ?? 'unknown error')); + } + $pluginId = (int) ($firstInstall['plugin_id'] ?? 0); + if ($pluginId <= 0) { + throw new RuntimeException('Initial install did not return a plugin ID.'); + } + + $db->query("UPDATE plugins SET is_active = 1 WHERE id = {$pluginId}"); + $db->query("INSERT INTO plugin_settings (plugin_id, setting_key, setting_value, autoload) VALUES ({$pluginId}, 'kept_setting', 'kept value', 1)"); + $db->query("INSERT INTO plugin_data (plugin_id, data_key, data_value, data_type) VALUES ({$pluginId}, 'kept_data', 'kept value', 'string')"); + $db->query("INSERT INTO plugin_hooks (plugin_id, hook_name, callback_class, callback_method, priority, is_active) VALUES ({$pluginId}, 'test.update', '{$className}', 'handleUpdate', 10, 1)"); + + $zipV2 = pzu_create_zip($slug, $className, '1.1.0', 'Disposable plugin v2'); + $update = $manager->installFromZip($zipV2); + if (($update['success'] ?? false) !== true || ($update['updated'] ?? false) !== true) { + throw new RuntimeException('ZIP update failed: ' . ($update['message'] ?? 'unknown error')); + } + if ((int) ($update['plugin_id'] ?? 0) !== $pluginId) { + throw new RuntimeException('ZIP update changed the plugin ID.'); + } + + $row = $db->query("SELECT version, display_name, is_active FROM plugins WHERE id = {$pluginId}")->fetch_assoc(); + $settings = (int) $db->query("SELECT COUNT(*) FROM plugin_settings WHERE plugin_id = {$pluginId} AND setting_key = 'kept_setting'")->fetch_row()[0]; + $data = (int) $db->query("SELECT COUNT(*) FROM plugin_data WHERE plugin_id = {$pluginId} AND data_key = 'kept_data'")->fetch_row()[0]; + $hooks = (int) $db->query("SELECT COUNT(*) FROM plugin_hooks WHERE plugin_id = {$pluginId} AND hook_name = 'test.update'")->fetch_row()[0]; + + if (!is_array($row) || $row['version'] !== '1.1.0' || $row['display_name'] !== 'Disposable plugin v2') { + throw new RuntimeException('ZIP update did not persist the replacement manifest.'); + } + if ((int) $row['is_active'] !== 1 || $settings !== 1 || $data !== 1 || $hooks !== 1) { + throw new RuntimeException('ZIP update did not preserve plugin state and related data.'); + } + if (!is_file($pluginDir . '/wrapper.php')) { + throw new RuntimeException('ZIP update did not promote the replacement package.'); + } + + echo "PASS: existing plugin ZIP update keeps ID, active state, settings, data and hooks\n"; +} finally { + if ($pluginId > 0) { + $db->query("DELETE FROM plugins WHERE id = {$pluginId}"); + } + pzu_delete_directory($pluginDir); + if (is_string($zipV1)) { + @unlink($zipV1); + } + if (is_string($zipV2)) { + @unlink($zipV2); + } + $db->close(); +}