Files
zammad/spec/models/package_spec.rb
Rolf Schmidt f6114a6aca Maintenance: Allow building container images with preinstalled addon packages.
Approved-by: Martin Gruner <mg@zammad.com>

Co-authored-by: Martin Gruner <mg@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
2026-09-10 11:41:29 +02:00

542 lines
19 KiB
Ruby

# Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
require 'models/concerns/has_audit_logs_examples'
RSpec.describe Package, type: :model do
it_behaves_like 'HasAuditLogs', update_attribute: 'version', update_value: '1.0.2' do
subject { described_class.create!(name: 'TestPackageAuditLog', version: '1.0.1', vendor: 'Zammad Foundation', state: 'installed', created_by_id: 1, updated_by_id: 1) }
end
# cleanup package files
after :all do # rubocop:disable RSpec/BeforeAfterAll
%w[example.rb app/controllers/test_controller.rb].each do |file|
next if !Rails.root.join(file).exist?
Rails.root.join(file).delete
end
end
def get_package_structure(name, files, version = '1.0.1', dependencies = '{}')
optional_js = ''
if dependencies.present?
optional_js += "\"dependencies\": #{dependencies},"
end
<<-JSON
{
"name": "#{name}",
"version": "#{version}",
"vendor": "Zammad Foundation",
"license": "ABC",
"url": "https://zammad.org/",
#{optional_js}
"description": [
{
"language": "en",
"text": "some description"
}
],
"files": #{files}
}
JSON
end
let(:package_zpm_files_json) do
<<-JSON
[
{
"permission": "644",
"location": "example.rb",
"content": "YWJjw6TDtsO8w58="
},
{
"permission": "644",
"location": "app/controllers/test_controller.rb",
"content": "YWJjw6TDtsO8w58="
}
]
JSON
end
let(:package_name) { 'UnitTestSample' }
let(:package_zpm_json) { get_package_structure(package_name, package_zpm_files_json) }
let(:old_package_zpm_json) { get_package_structure(package_name, package_zpm_files_json, '1.0.0') }
let(:new_package_zpm_json) { get_package_structure(package_name, package_zpm_files_json, '1.0.2') }
context 'when performing different package actions' do
context 'when installing a package' do
it 'does install package' do
expect { described_class.install(string: package_zpm_json) }
.to change(described_class, :count)
.and change(Store, :count)
end
end
context 'when reinstalling a package' do
before do
described_class.install(string: package_zpm_json)
end
it 'does not reinstall package' do
expect { described_class.reinstall(package_name) }
.to not_change(described_class, :count)
.and not_change(Store, :count)
end
end
context 'when installing a package again' do
before do
described_class.install(string: package_zpm_json)
end
it 'does not install package' do
expect { described_class.install(string: package_zpm_json) }
.to raise_error(RuntimeError)
.and not_change(described_class, :count)
.and not_change(Store, :count)
end
end
context 'when installing a package with a lower version' do
before do
described_class.install(string: package_zpm_json)
end
it 'does not install package' do
expect { described_class.install(string: old_package_zpm_json) }
.to raise_error(RuntimeError)
.and not_change(described_class, :count)
.and not_change(Store, :count)
end
end
context 'when upgrading a package' do
before do
described_class.install(string: package_zpm_json)
end
it 'does install package' do
expect { described_class.install(string: new_package_zpm_json) }
.to not_raise_error
.and not_change(described_class, :count)
.and change(Store, :count)
end
end
context 'when installing + uninstalling a package' do
before do
described_class.install(string: package_zpm_json)
end
it 'does install + uninstall the package' do
expect { described_class.uninstall(string: package_zpm_json) }
.to not_raise_error
.and change(described_class, :count)
.and not_change(Store, :count)
end
end
context 'when installing a package without writing files' do
before do
Rails.root.join('example.rb').delete if Rails.root.join('example.rb').exist?
end
it 'does install the package without touching the file system' do
expect { described_class.install(string: package_zpm_json, write_files: false) }
.to change(described_class, :count)
.and change(Store, :count)
.and not_change { Rails.root.join('example.rb').exist? }
end
end
context 'when uninstalling a package without removing files' do
before do
described_class.install(string: package_zpm_json)
end
it 'does uninstall the package without touching the file system' do
expect { described_class.uninstall(string: package_zpm_json, remove_files: false) }
.to change(described_class, :count).by(-1)
.and not_change { Rails.root.join('example.rb').exist? }
end
end
context 'when installing packages from a directory' do
let(:install_dir) { Rails.root.join('tmp/spec_package_install_dir') }
let(:dependent_package_zpm_json) { get_package_structure('UnitTestSampleDependent', '[]', '1.0.1', "{ \"#{package_name}\": \">= 1.0.1\" }") }
before do
FileUtils.mkdir_p(install_dir)
# File names are chosen so that plain glob order would install the dependent package first.
File.write(install_dir.join('01_dependent.zpm'), dependent_package_zpm_json)
File.write(install_dir.join('02_dependency.zpm'), package_zpm_json)
end
after do
FileUtils.rm_rf(install_dir)
Rails.root.join('example.rb.save').delete if Rails.root.join('example.rb.save').exist?
end
it 'does install all packages in dependency order' do
expect { described_class.install_dir(install_dir.to_s) }
.to change(described_class, :count).by(2)
end
it 'does not install already installed packages again' do
described_class.install_dir(install_dir.to_s)
expect { described_class.install_dir(install_dir.to_s) }
.to not_change(described_class, :count)
end
it 'does install packages without touching the file system' do
Rails.root.join('example.rb').delete if Rails.root.join('example.rb').exist?
expect { described_class.install_dir(install_dir.to_s, write_files: false) }
.to change(described_class, :count).by(2)
.and not_change { Rails.root.join('example.rb').exist? }
end
context 'with a chain of dependencies' do
let(:chained_package_zpm_json) { get_package_structure('UnitTestSampleChained', '[]', '1.0.1', '{ "UnitTestSampleDependent": ">= 1.0.1" }') }
before do
File.write(install_dir.join('00_chained.zpm'), chained_package_zpm_json)
end
it 'does install all packages in dependency order' do
expect { described_class.install_dir(install_dir.to_s) }
.to change(described_class, :count).by(3)
end
end
context 'when upgrading a package another installed package depends on' do
before do
described_class.install_dir(install_dir.to_s)
Auth::RequestCache.clear
File.write(install_dir.join('02_dependency.zpm'), new_package_zpm_json)
end
it 'does upgrade the package' do
expect { described_class.install_dir(install_dir.to_s) }
.to not_change(described_class, :count)
.and change { described_class.find_by(name: package_name).version }.to('1.0.2')
end
end
end
context 'when uninstalling packages from a directory' do
let(:uninstall_dir) { Rails.root.join('tmp/spec_package_uninstall_dir') }
let(:dependent_package_zpm_json) { get_package_structure('UnitTestSampleDependent', '[]', '1.0.1', "{ \"#{package_name}\": \">= 1.0.1\" }") }
before do
FileUtils.mkdir_p(uninstall_dir)
# File names are chosen so that plain glob order would uninstall the dependency first, which must fail.
File.write(uninstall_dir.join('01_dependency.zpm'), package_zpm_json)
File.write(uninstall_dir.join('02_dependent.zpm'), dependent_package_zpm_json)
described_class.install(string: package_zpm_json)
Auth::RequestCache.clear
described_class.install(string: dependent_package_zpm_json)
Auth::RequestCache.clear
end
after do
FileUtils.rm_rf(uninstall_dir)
end
it 'does uninstall all packages in reverse dependency order' do
expect { described_class.uninstall_dir(uninstall_dir.to_s) }
.to change(described_class, :count).by(-2)
end
it 'does skip packages which are not installed' do
described_class.uninstall_dir(uninstall_dir.to_s)
expect { described_class.uninstall_dir(uninstall_dir.to_s) }
.to not_change(described_class, :count)
end
it 'does uninstall packages without touching the file system' do
expect { described_class.uninstall_dir(uninstall_dir.to_s, remove_files: false) }
.to change(described_class, :count).by(-2)
.and not_change { Rails.root.join('example.rb').exist? }
end
it 'does uninstall the installed version regardless of the staged version' do
File.write(uninstall_dir.join('01_dependency.zpm'), get_package_structure(package_name, package_zpm_files_json, '9.9.9'))
expect { described_class.uninstall_dir(uninstall_dir.to_s) }
.to change(described_class, :count).by(-2)
end
context 'when an installed package outside the directory depends on a staged package' do
before do
uninstall_dir.join('02_dependent.zpm').delete
end
it 'does raise an error and keep the packages' do
expect { described_class.uninstall_dir(uninstall_dir.to_s) }
.to raise_error(%r{required dependencies})
.and not_change(described_class, :count)
end
end
context 'with package migrations' do
let(:migration_root) { Dir.mktmpdir('package-uninstall-dir', Rails.root.join('tmp')) }
let(:migration_dir) { File.join(migration_root, 'db/addon', package_name.underscore) }
let(:role) { create(:role) }
before do
FileUtils.mkdir_p(migration_dir)
File.write(File.join(migration_dir, '20260101000000_uninstall_dir_package_test.rb'), <<~MIGRATION)
class UninstallDirPackageTest < ActiveRecord::Migration[8.0]
def self.up
Role.find(#{role.id}).update!(name: 'Package Migrated')
end
def self.down
Role.find(#{role.id}).update!(name: 'Package Reverted')
end
end
MIGRATION
allow(Package::Migration).to receive(:root).and_return(migration_root)
Package::Migration.migrate(package_name)
end
after do
FileUtils.remove_entry(migration_root)
end
it 'does execute the down migrations of the uninstalled packages', :aggregate_failures do
expect(role.reload.name).to eq('Package Migrated')
expect { described_class.uninstall_dir(uninstall_dir.to_s) }
.to change { Package::Migration.where(name: package_name.underscore).count }.by(-1)
expect(role.reload.name).to eq('Package Reverted')
end
end
end
context 'when verify package install' do
context 'when verify is ok' do
it 'returns no verify issues' do
package = described_class.install(string: package_zpm_json)
expect(package.verify).to be_nil
end
end
context 'when verify is not ok' do
it 'returns verify issues' do
package = described_class.install(string: package_zpm_json)
Rails.root.join('example.rb').delete
expect(package.verify).not_to be_nil
end
end
end
end
context 'with different file locations' do
context 'with correct file locations' do
it 'installation should work' do
expect(described_class.install(string: package_zpm_json)).to be_truthy
end
end
shared_examples 'check not allowed file location' do |file_location|
let(:package_zpm_files_json) do
<<-JSON
[
{
"permission": "644",
"location": "example.rb",
"content": "YWJjw6TDtsO8w58="
},
{
"permission": "644",
"location": "#{file_location}",
"content": "YWJjw6TDtsO8w58="
}
]
JSON
end
it 'installation should raise a error and package/store should not be present, because of not allowed file location' do
expect { described_class.install(string: package_zpm_json) }
.to raise_error(RuntimeError)
.and not_change(described_class, :count)
.and not_change(Store, :count)
end
end
context "with not allowed file location part: '..'" do
include_examples 'check not allowed file location', '../../../../../tmp/test_controller.rb'
end
context "with not allowed file location part: '%2e%2e'" do
include_examples 'check not allowed file location', '%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/tmp/test_controller.rb'
end
end
describe 'Multiple uninstalling packages will course missing backup files #4577' do
let(:core_file) { Rails.root.join('app/models/ticket.rb') }
let(:orig_content) { File.read(core_file) }
let(:package_zpm_files_json) do
<<-JSON
[
{
"permission": "644",
"location": "app/models/ticket.rb",
"content": "YWJjw6TDtsO8w58="
}
]
JSON
end
before do
orig_content
end
def expect_install_package
described_class.install(string: package_zpm_json)
expect(File.exist?(core_file)).to be(true)
expect(File.read(core_file)).to eq('abcäöüß')
expect(File.exist?("#{core_file}.save")).to be(true)
expect(File.read("#{core_file}.save")).to eq(orig_content)
expect(described_class.last.state).to eq('installed')
end
def expect_uninstall_package_files
described_class.uninstall(string: package_zpm_json, migration_not_down: true, reinstall: true)
expect(File.exist?(core_file)).to be(true)
expect(File.read(core_file)).to eq(orig_content)
expect(File.exist?("#{core_file}.save")).to be(false)
expect(described_class.last.state).to eq('uninstalled')
end
def expect_reinstall_package
described_class.reinstall(package_name)
expect(File.exist?(core_file)).to be(true)
expect(File.read(core_file)).to eq('abcäöüß')
expect(File.exist?("#{core_file}.save")).to be(true)
expect(File.read("#{core_file}.save")).to eq(orig_content)
expect(described_class.last.state).to eq('installed')
end
def expect_uninstall_package
described_class.uninstall(string: package_zpm_json)
expect(File.exist?(core_file)).to be(true)
expect(File.read(core_file)).to eq(orig_content)
expect(File.exist?("#{core_file}.save")).to be(false)
expect(File.read(core_file)).to eq(orig_content)
end
it 'does support the classic package migration path but with multiple uninstalls' do
expect_install_package
expect_uninstall_package_files
expect_uninstall_package_files
expect_reinstall_package
expect_uninstall_package
end
it 'does have a proper package state after multiple reinstalls' do
expect_install_package
expect_reinstall_package
expect_reinstall_package
expect_uninstall_package
end
end
describe 'Vendor url in installed package is the zammad instance url #4753' do
it 'does have a url for the package' do
described_class.install(string: package_zpm_json)
expect(described_class.last.url).to eq('https://zammad.org/')
end
end
describe 'Package: Missing backup files for files with the same content #5012' do
let(:package_v1_files) do
<<-JSON
[
{
"permission": "644",
"location": "lib/version.rb",
"content": "#{Base64.strict_encode64(File.read('lib/version.rb')).strip}"
}
]
JSON
end
let(:package_v2_files) do
<<-JSON
[]
JSON
end
let(:package_v1) { get_package_structure(package_name, package_v1_files, '1.0.0') }
let(:package_v2) { get_package_structure(package_name, package_v2_files, '1.0.1') }
it 'does not lose core files when patched by package and released in future updates of zammad' do
described_class.install(string: package_v1)
described_class.install(string: package_v2)
expect(File.exist?('lib/version.rb')).to be(true)
end
end
describe 'Package: File conflict with packages which include the same file location #5014' do
let(:package_1) { get_package_structure('PackageA', package_zpm_files_json, '1.0.0') }
let(:package_2) { get_package_structure('PackageB', package_zpm_files_json, '1.0.0') }
it 'does not allow to patch the same file twice via package' do
described_class.install(string: package_1)
expect { described_class.install(string: package_2) }.to raise_error("Can't create file, because file 'example.rb' is already provided by package 'PackageA'!")
end
end
describe 'Dependencies' do
let(:package_1) { get_package_structure('PackageA', package_zpm_files_json, '1.0.0') }
let(:package_2) { get_package_structure('PackageB', '[]', '1.0.0', '{ "PackageA": ">= 1.0.0" }') }
let(:package_3_invalid) { get_package_structure('PackageB', '[]', '1.0.0', '{ "PackageA": "!= 1.0.0" }') }
let(:package_4) { get_package_structure('PackageC', '[]', '1.0.0') }
it 'does not install package if dependencies are missing' do
expect { described_class.install(string: package_2) }.to raise_error(RuntimeError, "Can't install package, because of missing dependencies: PackageA >= 1.0.0!")
end
it 'does not install package if dependencies are invalid' do
expect { described_class.install(string: package_3_invalid) }.to raise_error(RuntimeError, "Can't install package, because of invalid dependencies: PackageA != 1.0.0!")
end
it 'does install package if dependencies are present' do
described_class.install(string: package_1)
expect { described_class.install(string: package_2) }.not_to raise_error
end
it 'does not uninstall package if dependencies are required' do
described_class.install(string: package_1)
described_class.install(string: package_2)
expect { described_class.uninstall(string: package_1) }.to raise_error(RuntimeError, "Can't uninstall package, because of required dependencies: PackageB requires PackageA!")
end
it 'does uninstall package if dependencies are not required' do
described_class.install(string: package_1)
described_class.install(string: package_2)
described_class.uninstall(string: package_2)
expect { described_class.uninstall(string: package_1) }.not_to raise_error
end
it 'does install and uninstall without any dependencies' do
described_class.install(string: package_4)
expect { described_class.uninstall(string: package_4) }.not_to raise_error
end
end
end