diff --git a/src/platform/backends/shared/base_snapshot.cpp b/src/platform/backends/shared/base_snapshot.cpp index d613993b95..b0cfd18a93 100644 --- a/src/platform/backends/shared/base_snapshot.cpp +++ b/src/platform/backends/shared/base_snapshot.cpp @@ -60,8 +60,8 @@ QJsonObject read_snapshot_json(const QString& filename) if (const auto json = QJsonDocument::fromJson(data, &parse_error).object(); parse_error.error) throw std::runtime_error{fmt::format("Could not parse snapshot JSON; error: {}; file: {}", - file.fileName(), - parse_error.errorString())}; + parse_error.errorString(), + file.fileName())}; else if (json.isEmpty()) throw std::runtime_error{fmt::format("Empty snapshot JSON: {}", file.fileName())}; else @@ -124,7 +124,6 @@ mp::BaseSnapshot::BaseSnapshot(const std::string& name, // NOLINT(modernize-p storage_dir{storage_dir}, captured{captured} { - assert(index > 0 && "snapshot indices need to start at 1"); using St = VirtualMachine::State; if (state != St::off && state != St::stopped) throw std::runtime_error{fmt::format("Unsupported VM state in snapshot: {}", static_cast(state))}; @@ -161,6 +160,7 @@ mp::BaseSnapshot::BaseSnapshot(const std::string& name, vm.instance_directory(), /*captured=*/false} { + assert(index > 0 && "snapshot indices need to start at 1"); } mp::BaseSnapshot::BaseSnapshot(const QString& filename, VirtualMachine& vm) @@ -238,7 +238,7 @@ auto mp::BaseSnapshot::erase_helper() auto deleting_filepath = tmp_dir->filePath(snapshot_filename); QFile snapshot_file{snapshot_filepath}; - if (!MP_FILEOPS.rename(snapshot_file, deleting_filepath)) + if (!MP_FILEOPS.rename(snapshot_file, deleting_filepath) && snapshot_file.exists()) throw std::runtime_error{ fmt::format("Failed to move snapshot file to temporary destination: {}", deleting_filepath)}; diff --git a/src/platform/backends/shared/base_virtual_machine.cpp b/src/platform/backends/shared/base_virtual_machine.cpp index 89006143fc..323218c980 100644 --- a/src/platform/backends/shared/base_virtual_machine.cpp +++ b/src/platform/backends/shared/base_virtual_machine.cpp @@ -548,12 +548,12 @@ std::shared_ptr BaseVirtualMachine::make_specific_snapshot(const std:: const VMSpecs& /*specs*/, std::shared_ptr /*parent*/) { - throw NotImplementedOnThisBackendException{"Snapshots"}; + throw NotImplementedOnThisBackendException{"snapshots"}; } std::shared_ptr BaseVirtualMachine::make_specific_snapshot(const QString& /*filename*/) { - throw NotImplementedOnThisBackendException{"Snapshots"}; + throw NotImplementedOnThisBackendException{"snapshots"}; } } // namespace multipass diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0bc8f6050f..a2ca034862 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -54,6 +54,7 @@ add_executable(multipass_tests temp_file.cpp test_alias_dict.cpp test_argparser.cpp + test_base_snapshot.cpp test_base_virtual_machine.cpp test_base_virtual_machine_factory.cpp test_basic_process.cpp @@ -80,6 +81,7 @@ add_executable(multipass_tests test_image_vault.cpp test_instance_settings_handler.cpp test_ip_address.cpp + test_json_utils.cpp test_memory_size.cpp test_mock_standard_paths.cpp test_new_release_monitor.cpp @@ -88,6 +90,7 @@ add_executable(multipass_tests test_petname.cpp test_platform_shared.cpp test_private_pass_provider.cpp + test_qemu_snapshot.cpp test_qemuimg_process_spec.cpp test_remote_settings_handler.cpp test_setting_specs.cpp diff --git a/tests/common.h b/tests/common.h index d35d50d131..3967417afe 100644 --- a/tests/common.h +++ b/tests/common.h @@ -62,6 +62,21 @@ // work around warning: https://github.com/google/googletest/issues/2271#issuecomment-665742471 #define MP_TYPED_TEST_SUITE(suite_name, types_param) TYPED_TEST_SUITE(suite_name, types_param, ) +// Macros to make a mock delegate calls on a base class by default. +// For example, if `mock_widget` is an object of type `MockWidget` which mocks `Widget`, one can say: +// `MP_DELEGATE_MOCK_CALLS_ON_BASE(mock_widget, Widget, render);` +// This will cause calls to `mock_widget.render()` to delegate on the base implementation in `MockWidget`. +#define MP_DELEGATE_MOCK_CALLS_ON_BASE(mock, method, BaseT) \ + MP_DELEGATE_MOCK_CALLS_ON_BASE_WITH_MATCHERS(mock, method, BaseT, ) + +// This second form accepts matchers, which are useful to disambiguate overloaded methods. For example: +// `MP_DELEGATE_MOCK_CALLS_ON_BASE_WITH_MATCHERS(mock_widget, Widget, render, (A()))` +// This will redirect the version of `MockWidget::render` that takes one argument of type `Canvas`. +#define MP_DELEGATE_MOCK_CALLS_ON_BASE_WITH_MATCHERS(mock, method, BaseT, ...) \ + ON_CALL(mock, method __VA_ARGS__).WillByDefault([&mock](auto&&... args) { \ + return mock.BaseT::method(std::forward(args)...); \ + }) + // Teach GTest to print Qt stuff QT_BEGIN_NAMESPACE class QString; diff --git a/tests/mock_snapshot.h b/tests/mock_snapshot.h new file mode 100644 index 0000000000..a2cbc95785 --- /dev/null +++ b/tests/mock_snapshot.h @@ -0,0 +1,56 @@ +/* + * Copyright (C) Canonical, Ltd. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef MULTIPASS_MOCK_SNAPSHOT_H +#define MULTIPASS_MOCK_SNAPSHOT_H + +#include "common.h" + +#include +#include +#include + +namespace mp = multipass; + +namespace multipass::test +{ +struct MockSnapshot : public mp::Snapshot +{ + MOCK_METHOD(int, get_index, (), (const, noexcept, override)); + MOCK_METHOD(std::string, get_name, (), (const, override)); + MOCK_METHOD(std::string, get_comment, (), (const, override)); + MOCK_METHOD(QDateTime, get_creation_timestamp, (), (const, noexcept, override)); + MOCK_METHOD(int, get_num_cores, (), (const, noexcept, override)); + MOCK_METHOD(mp::MemorySize, get_mem_size, (), (const, noexcept, override)); + MOCK_METHOD(mp::MemorySize, get_disk_space, (), (const, noexcept, override)); + MOCK_METHOD(mp::VirtualMachine::State, get_state, (), (const, noexcept, override)); + MOCK_METHOD((const std::unordered_map&), get_mounts, (), (const, noexcept, override)); + MOCK_METHOD(const QJsonObject&, get_metadata, (), (const, noexcept, override)); + MOCK_METHOD(std::shared_ptr, get_parent, (), (const, override)); + MOCK_METHOD(std::shared_ptr, get_parent, (), (override)); + MOCK_METHOD(std::string, get_parents_name, (), (const, override)); + MOCK_METHOD(int, get_parents_index, (), (const, override)); + MOCK_METHOD(void, set_name, (const std::string&), (override)); + MOCK_METHOD(void, set_comment, (const std::string&), (override)); + MOCK_METHOD(void, set_parent, (std::shared_ptr), (override)); + MOCK_METHOD(void, capture, (), (override)); + MOCK_METHOD(void, erase, (), (override)); + MOCK_METHOD(void, apply, (), (override)); +}; +} // namespace multipass::test + +#endif // MULTIPASS_MOCK_SNAPSHOT_H diff --git a/tests/mock_virtual_machine.h b/tests/mock_virtual_machine.h index eb596d6f69..bda3ffac52 100644 --- a/tests/mock_virtual_machine.h +++ b/tests/mock_virtual_machine.h @@ -25,6 +25,8 @@ #include #include +#include + using namespace testing; namespace multipass @@ -41,7 +43,7 @@ struct MockVirtualMachineT : public T template MockVirtualMachineT(std::unique_ptr&& tmp_dir, Args&&... args) - : T{std::forward(args)..., tmp_dir->path()} + : T{std::forward(args)..., tmp_dir->path()}, tmp_dir{std::move(tmp_dir)} { ON_CALL(*this, current_state()).WillByDefault(Return(multipass::VirtualMachine::State::off)); ON_CALL(*this, ssh_port()).WillByDefault(Return(42)); @@ -76,7 +78,7 @@ struct MockVirtualMachineT : public T make_native_mount_handler, (const SSHKeyProvider*, const std::string&, const VMMount&), (override)); - MOCK_METHOD(VirtualMachine::SnapshotVista, view_snapshots, (), (const, override, noexcept)); + MOCK_METHOD(VirtualMachine::SnapshotVista, view_snapshots, (), (const, override)); MOCK_METHOD(int, get_num_snapshots, (), (const, override)); MOCK_METHOD(std::shared_ptr, get_snapshot, (const std::string&), (const, override)); MOCK_METHOD(std::shared_ptr, get_snapshot, (int index), (const, override)); @@ -92,6 +94,8 @@ struct MockVirtualMachineT : public T MOCK_METHOD(void, load_snapshots, (), (override)); MOCK_METHOD(std::vector, get_childrens_names, (const Snapshot*), (const, override)); MOCK_METHOD(int, get_snapshot_count, (), (const, override)); + + std::unique_ptr tmp_dir; }; using MockVirtualMachine = MockVirtualMachineT<>; diff --git a/tests/qemu/test_qemu_backend.cpp b/tests/qemu/test_qemu_backend.cpp index bc582ed9d2..9e27039a62 100644 --- a/tests/qemu/test_qemu_backend.cpp +++ b/tests/qemu/test_qemu_backend.cpp @@ -20,7 +20,10 @@ #include "tests/common.h" #include "tests/mock_environment_helpers.h" #include "tests/mock_process_factory.h" +#include "tests/mock_snapshot.h" #include "tests/mock_status_monitor.h" +#include "tests/mock_virtual_machine.h" +#include "tests/path.h" #include "tests/stub_process_factory.h" #include "tests/stub_ssh_key_provider.h" #include "tests/stub_status_monitor.h" @@ -35,8 +38,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -675,6 +680,63 @@ TEST_F(QemuBackend, ssh_hostname_timeout_throws_and_sets_unknown_state) EXPECT_EQ(machine.state, mp::VirtualMachine::State::unknown); } +struct PublicSnapshotMakingQemuVM : public mpt::MockVirtualMachineT +{ + using mpt::MockVirtualMachineT::MockVirtualMachineT; + using mp::QemuVirtualMachine::make_specific_snapshot; + using mp::QemuVirtualMachine::require_snapshots_support; +}; + +TEST_F(QemuBackend, supportsSnapshots) +{ + PublicSnapshotMakingQemuVM vm{"asdf"}; + EXPECT_NO_THROW(vm.require_snapshots_support()); +} + +TEST_F(QemuBackend, createsQemuSnapshotsFromSpecs) +{ + PublicSnapshotMakingQemuVM machine{"mock-qemu-vm"}; + + auto snapshot_name = "elvis"; + auto snapshot_comment = "has left the building"; + const mp::VMSpecs specs{2, + mp::MemorySize{"3.21G"}, + mp::MemorySize{"4.32M"}, + "00:00:00:00:00:00", + {}, + "asdf", + mp::VirtualMachine::State::stopped, + {}, + false, + {}, + {}}; + auto snapshot = machine.make_specific_snapshot(snapshot_name, snapshot_comment, specs, nullptr); + EXPECT_EQ(snapshot->get_name(), snapshot_name); + EXPECT_EQ(snapshot->get_comment(), snapshot_comment); + EXPECT_EQ(snapshot->get_num_cores(), specs.num_cores); + EXPECT_EQ(snapshot->get_mem_size(), specs.mem_size); + EXPECT_EQ(snapshot->get_disk_space(), specs.disk_space); + EXPECT_EQ(snapshot->get_state(), specs.state); + EXPECT_EQ(snapshot->get_parent(), nullptr); +} + +TEST_F(QemuBackend, createsQemuSnapshotsFromJsonFile) +{ + PublicSnapshotMakingQemuVM machine{"mock-qemu-vm"}; + + const auto parent = std::make_shared(); + EXPECT_CALL(machine, get_snapshot(2)).WillOnce(Return(parent)); + + auto snapshot = machine.make_specific_snapshot(mpt::test_data_path_for("test_snapshot.json")); + EXPECT_EQ(snapshot->get_name(), "snapshot3"); + EXPECT_EQ(snapshot->get_comment(), "A comment"); + EXPECT_EQ(snapshot->get_num_cores(), 1); + EXPECT_EQ(snapshot->get_mem_size(), mp::MemorySize{"1G"}); + EXPECT_EQ(snapshot->get_disk_space(), mp::MemorySize{"5G"}); + EXPECT_EQ(snapshot->get_state(), mp::VirtualMachine::State::off); + EXPECT_EQ(snapshot->get_parent(), parent); +} + TEST_F(QemuBackend, lists_no_networks) { EXPECT_CALL(*mock_qemu_platform_factory, make_qemu_platform(_)).WillOnce([this](auto...) { diff --git a/tests/stub_virtual_machine.h b/tests/stub_virtual_machine.h index 4c316b06b7..9a159135e5 100644 --- a/tests/stub_virtual_machine.h +++ b/tests/stub_virtual_machine.h @@ -38,7 +38,7 @@ struct StubVirtualMachine final : public multipass::VirtualMachine { } - StubVirtualMachine(const std::string& name, std::unique_ptr&& tmp_dir) + StubVirtualMachine(const std::string& name, std::unique_ptr tmp_dir) : VirtualMachine{name, tmp_dir->path()}, tmp_dir{std::move(tmp_dir)} { } diff --git a/tests/stub_virtual_machine_factory.h b/tests/stub_virtual_machine_factory.h index 93305b094d..b4d3063ecd 100644 --- a/tests/stub_virtual_machine_factory.h +++ b/tests/stub_virtual_machine_factory.h @@ -34,8 +34,8 @@ struct StubVirtualMachineFactory : public multipass::BaseVirtualMachineFactory { } - StubVirtualMachineFactory(std::unique_ptr&& tmp_dir) - : mp::BaseVirtualMachineFactory{tmp_dir->path()}, tmp_dir{std::move(tmp_dir)} + StubVirtualMachineFactory(std::unique_ptr tmp_dir) + : multipass::BaseVirtualMachineFactory{tmp_dir->path()}, tmp_dir{std::move(tmp_dir)} { } diff --git a/tests/test_base_snapshot.cpp b/tests/test_base_snapshot.cpp new file mode 100644 index 0000000000..576e16cdf9 --- /dev/null +++ b/tests/test_base_snapshot.cpp @@ -0,0 +1,690 @@ +/* + * Copyright (C) Canonical, Ltd. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "common.h" +#include "file_operations.h" +#include "mock_file_ops.h" +#include "mock_virtual_machine.h" +#include "path.h" + +#include +#include +#include + +#include +#include +#include + +#include +#include + +namespace mp = multipass; +namespace mpt = multipass::test; +using namespace testing; + +namespace +{ +class MockBaseSnapshot : public mp::BaseSnapshot +{ +public: + using mp::BaseSnapshot::BaseSnapshot; + + MOCK_METHOD(void, capture_impl, (), (override)); + MOCK_METHOD(void, erase_impl, (), (override)); + MOCK_METHOD(void, apply_impl, (), (override)); + + friend bool operator==(const MockBaseSnapshot& a, const MockBaseSnapshot& b); +}; + +bool operator==(const MockBaseSnapshot& a, const MockBaseSnapshot& b) +{ + return std::tuple(a.get_index(), + a.get_name(), + a.get_comment(), + a.get_creation_timestamp(), + a.get_num_cores(), + a.get_mem_size(), + a.get_disk_space(), + a.get_state(), + a.get_mounts(), + a.get_metadata(), + a.get_parent(), + a.get_id()) == std::tuple(b.get_index(), + b.get_name(), + b.get_comment(), + b.get_creation_timestamp(), + b.get_num_cores(), + b.get_mem_size(), + b.get_disk_space(), + b.get_state(), + b.get_mounts(), + b.get_metadata(), + b.get_parent(), + b.get_id()); +} + +struct TestBaseSnapshot : public Test +{ + static mp::VMSpecs stub_specs() + { + mp::VMSpecs ret{}; + ret.num_cores = 3; + ret.mem_size = mp::MemorySize{"1.5G"}; + ret.disk_space = mp::MemorySize{"10G"}; + ret.default_mac_address = "12:12:12:12:12:12"; + + return ret; + } + + static QJsonObject test_snapshot_json() + { + static auto json_doc = [] { + QJsonParseError parse_error{}; + const auto ret = QJsonDocument::fromJson(mpt::load_test_file(test_json_filename), &parse_error); + if (parse_error.error) + throw std::runtime_error{ + fmt::format("Bad JSON test data in {}; error: {}", test_json_filename, parse_error.errorString())}; + return ret; + }(); + + return json_doc.object(); + } + + static void mod_snapshot_json(QJsonObject& json, const QString& key, const QJsonValue& new_value) + { + const auto snapshot_key = QStringLiteral("snapshot"); + auto snapshot_json_ref = json[snapshot_key]; + auto snapshot_json_copy = snapshot_json_ref.toObject(); + snapshot_json_copy[key] = new_value; + snapshot_json_ref = snapshot_json_copy; + } + + QString plant_snapshot_json(const QJsonObject& object, const QString& filename = "snapshot.json") const + { + const auto file_path = vm.tmp_dir->filePath(filename); + + const QJsonDocument doc{object}; + mpt::make_file_with_content(file_path, doc.toJson().toStdString()); + + return file_path; + } + + QString derive_persisted_snapshot_file_path(int index) + { + return vm.tmp_dir->filePath(QString{"%1"}.arg(index, 4, 10, QLatin1Char('0')) + ".snapshot.json"); + } + + static constexpr auto* test_json_filename = "test_snapshot.json"; + mp::VMSpecs specs = stub_specs(); + NiceMock vm{"a-vm"}; + QString test_json_file_path = mpt::test_data_path_for(test_json_filename); +}; + +TEST_F(TestBaseSnapshot, adoptsGivenValidName) +{ + constexpr auto name = "a-name"; + auto snapshot = MockBaseSnapshot{name, "", nullptr, specs, vm}; + EXPECT_EQ(snapshot.get_name(), name); +} + +TEST_F(TestBaseSnapshot, rejectsEmptyName) +{ + const std::string empty{}; + MP_EXPECT_THROW_THAT((MockBaseSnapshot{empty, "asdf", nullptr, specs, vm}), + std::runtime_error, + mpt::match_what(HasSubstr("empty"))); +} + +TEST_F(TestBaseSnapshot, adoptsGivenComment) +{ + constexpr auto comment = "some comment"; + auto snapshot = MockBaseSnapshot{"whatever", comment, nullptr, specs, vm}; + EXPECT_EQ(snapshot.get_comment(), comment); +} + +TEST_F(TestBaseSnapshot, adoptsGivenParent) +{ + const auto parent = std::make_shared("root", "asdf", nullptr, specs, vm); + auto snapshot = MockBaseSnapshot{"descendant", "descends", parent, specs, vm}; + EXPECT_EQ(snapshot.get_parent(), parent); +} + +TEST_F(TestBaseSnapshot, adoptsNullParent) +{ + auto snapshot = MockBaseSnapshot{"descendant", "descends", nullptr, specs, vm}; + EXPECT_EQ(snapshot.get_parent(), nullptr); +} + +TEST_F(TestBaseSnapshot, adoptsGivenSpecs) +{ + auto snapshot = MockBaseSnapshot{"snapshot", "", nullptr, specs, vm}; + EXPECT_EQ(snapshot.get_num_cores(), specs.num_cores); + EXPECT_EQ(snapshot.get_mem_size(), specs.mem_size); + EXPECT_EQ(snapshot.get_disk_space(), specs.disk_space); + EXPECT_EQ(snapshot.get_state(), specs.state); + EXPECT_EQ(snapshot.get_mounts(), specs.mounts); + EXPECT_EQ(snapshot.get_metadata(), specs.metadata); +} + +TEST_F(TestBaseSnapshot, adoptsCustomMounts) +{ + specs.mounts["toto"] = + mp::VMMount{"src", {{123, 234}, {567, 678}}, {{19, 91}}, multipass::VMMount::MountType::Classic}; + specs.mounts["tata"] = + mp::VMMount{"fountain", {{234, 123}}, {{81, 18}, {9, 10}}, multipass::VMMount::MountType::Native}; + + auto snapshot = MockBaseSnapshot{"snapshot", "", nullptr, specs, vm}; + EXPECT_EQ(snapshot.get_mounts(), specs.mounts); +} + +TEST_F(TestBaseSnapshot, adoptsCustomMetadata) +{ + QJsonObject json; + QJsonObject data; + data.insert("an-int", 7); + data.insert("a-str", "str"); + json.insert("meta", data); + specs.metadata = json; + + auto snapshot = MockBaseSnapshot{"snapshot", "", nullptr, specs, vm}; + EXPECT_EQ(snapshot.get_metadata(), specs.metadata); +} + +TEST_F(TestBaseSnapshot, adoptsNextIndex) +{ + const int count = 123; + EXPECT_CALL(vm, get_snapshot_count).WillOnce(Return(count)); + + auto snapshot = MockBaseSnapshot{"tau", "ceti", nullptr, specs, vm}; + EXPECT_EQ(snapshot.get_index(), count + 1); +} + +TEST_F(TestBaseSnapshot, retrievesParentsProperties) +{ + constexpr auto parent_name = "parent"; + const int parent_index = 11; + + EXPECT_CALL(vm, get_snapshot_count).WillOnce(Return(parent_index - 1)).WillOnce(Return(31)); + + auto parent = std::make_shared(parent_name, "", nullptr, specs, vm); + + auto child = MockBaseSnapshot{"child", "", parent, specs, vm}; + EXPECT_EQ(child.get_parents_index(), parent_index); + EXPECT_EQ(child.get_parents_name(), parent_name); +} + +TEST_F(TestBaseSnapshot, adoptsCurrentTimestamp) +{ + auto before = QDateTime::currentDateTimeUtc(); + auto snapshot = MockBaseSnapshot{"foo", "", nullptr, specs, vm}; + auto after = QDateTime::currentDateTimeUtc(); + + EXPECT_GE(snapshot.get_creation_timestamp(), before); + EXPECT_LE(snapshot.get_creation_timestamp(), after); +} + +class TestSnapshotRejectedStates : public TestBaseSnapshot, public WithParamInterface +{ +}; + +TEST_P(TestSnapshotRejectedStates, rejectsActiveState) +{ + specs.state = GetParam(); + MP_EXPECT_THROW_THAT((MockBaseSnapshot{"snapshot", "comment", nullptr, specs, vm}), + std::runtime_error, + mpt::match_what(HasSubstr("Unsupported VM state"))); +} + +INSTANTIATE_TEST_SUITE_P(TestBaseSnapshot, + TestSnapshotRejectedStates, + Values(mp::VirtualMachine::State::starting, + mp::VirtualMachine::State::restarting, + mp::VirtualMachine::State::running, + mp::VirtualMachine::State::delayed_shutdown, + mp::VirtualMachine::State::suspending, + mp::VirtualMachine::State::suspended, + mp::VirtualMachine::State::unknown)); + +class TestSnapshotInvalidCores : public TestBaseSnapshot, public WithParamInterface +{ +}; + +TEST_P(TestSnapshotInvalidCores, rejectsInvalidNumberOfCores) +{ + specs.num_cores = GetParam(); + MP_EXPECT_THROW_THAT((MockBaseSnapshot{"snapshot", "comment", nullptr, specs, vm}), + std::runtime_error, + mpt::match_what(HasSubstr("Invalid number of cores"))); +} + +INSTANTIATE_TEST_SUITE_P(TestBaseSnapshot, TestSnapshotInvalidCores, Values(0, -1, -12345, -3e9)); + +TEST_F(TestBaseSnapshot, rejectsNullMemorySize) +{ + specs.mem_size = mp::MemorySize{"0B"}; + MP_EXPECT_THROW_THAT((MockBaseSnapshot{"snapshot", "comment", nullptr, specs, vm}), + std::runtime_error, + mpt::match_what(HasSubstr("Invalid memory size"))); +} + +TEST_F(TestBaseSnapshot, rejectsNullDiskSize) +{ + specs.disk_space = mp::MemorySize{"0B"}; + MP_EXPECT_THROW_THAT((MockBaseSnapshot{"snapshot", "comment", nullptr, specs, vm}), + std::runtime_error, + mpt::match_what(HasSubstr("Invalid disk size"))); +} + +TEST_F(TestBaseSnapshot, reconstructsFromJson) +{ + MockBaseSnapshot{test_json_file_path, vm}; +} + +TEST_F(TestBaseSnapshot, adoptsNameFromJson) +{ + constexpr auto* snapshot_name = "cheeseball"; + auto json = test_snapshot_json(); + mod_snapshot_json(json, "name", snapshot_name); + + auto snapshot = MockBaseSnapshot{plant_snapshot_json(json), vm}; + EXPECT_EQ(snapshot.get_name(), snapshot_name); +} + +TEST_F(TestBaseSnapshot, adoptsCommentFromJson) +{ + constexpr auto* snapshot_comment = "Look behind you, a three-headed monkey!"; + auto json = test_snapshot_json(); + mod_snapshot_json(json, "comment", snapshot_comment); + + auto snapshot = MockBaseSnapshot{plant_snapshot_json(json), vm}; + EXPECT_EQ(snapshot.get_comment(), snapshot_comment); +} + +TEST_F(TestBaseSnapshot, linksToParentFromJson) +{ + constexpr auto parent_idx = 42; + constexpr auto parent_name = "s42"; + auto json = test_snapshot_json(); + mod_snapshot_json(json, "parent", parent_idx); + + EXPECT_CALL(vm, get_snapshot(TypedEq(parent_idx))) + .WillOnce(Return(std::make_shared(parent_name, "mock parent snapshot", nullptr, specs, vm))); + + auto snapshot = MockBaseSnapshot{plant_snapshot_json(json), vm}; + EXPECT_EQ(snapshot.get_parents_name(), parent_name); +} + +TEST_F(TestBaseSnapshot, adoptsIndexFromJson) +{ + constexpr auto index = 31; + auto json = test_snapshot_json(); + mod_snapshot_json(json, "index", index); + + auto snapshot = MockBaseSnapshot{plant_snapshot_json(json), vm}; + EXPECT_EQ(snapshot.get_index(), index); +} + +TEST_F(TestBaseSnapshot, adoptsTimestampFromJson) +{ + constexpr auto timestamp = "1990-10-01T01:02:03.999Z"; + auto json = test_snapshot_json(); + mod_snapshot_json(json, "creation_timestamp", timestamp); + + auto snapshot = MockBaseSnapshot{plant_snapshot_json(json), vm}; + EXPECT_EQ(snapshot.get_creation_timestamp().toString(Qt::ISODateWithMs), timestamp); +} + +TEST_F(TestBaseSnapshot, adoptsNumCoresFromJson) +{ + constexpr auto num_cores = 9; + auto json = test_snapshot_json(); + mod_snapshot_json(json, "num_cores", num_cores); + + auto snapshot = MockBaseSnapshot{plant_snapshot_json(json), vm}; + EXPECT_EQ(snapshot.get_num_cores(), num_cores); +} + +TEST_F(TestBaseSnapshot, adoptsMemSizeFromJson) +{ + constexpr auto mem = "1073741824"; + auto json = test_snapshot_json(); + mod_snapshot_json(json, "mem_size", mem); + + auto snapshot = MockBaseSnapshot{plant_snapshot_json(json), vm}; + EXPECT_EQ(snapshot.get_mem_size().in_bytes(), QString{mem}.toInt()); +} + +TEST_F(TestBaseSnapshot, adoptsDiskSpaceFromJson) +{ + constexpr auto disk = "1073741824"; + auto json = test_snapshot_json(); + mod_snapshot_json(json, "disk_space", disk); + + auto snapshot = MockBaseSnapshot{plant_snapshot_json(json), vm}; + EXPECT_EQ(snapshot.get_disk_space().in_bytes(), QString{disk}.toInt()); +} + +TEST_F(TestBaseSnapshot, adoptsStateFromJson) +{ + constexpr auto state = mp::VirtualMachine::State::stopped; + auto json = test_snapshot_json(); + mod_snapshot_json(json, "state", static_cast(state)); + + auto snapshot = MockBaseSnapshot{plant_snapshot_json(json), vm}; + EXPECT_EQ(snapshot.get_state(), state); +} + +TEST_F(TestBaseSnapshot, adoptsMetadataFromJson) +{ + auto metadata = QJsonObject{}; + metadata["arguments"] = "Meathook:\n" + "You've got a real attitude problem!\n" + "\n" + "Guybrush Threepwood:\n" + "Well... you've got a real hair problem!\n" + "\n" + "Meathook:\n" + "You just don't know when to quit, do you?\n" + "\n" + "Guybrush Threepwood:\n" + "Neither did your barber."; + + auto json = test_snapshot_json(); + mod_snapshot_json(json, "metadata", metadata); + + auto snapshot = MockBaseSnapshot{plant_snapshot_json(json), vm}; + EXPECT_EQ(snapshot.get_metadata(), metadata); +} + +TEST_F(TestBaseSnapshot, adoptsMountsFromJson) +{ + constexpr auto src_path = "You fight like a dairy farmer."; + constexpr auto dst_path = "How appropriate. You fight like a cow."; + constexpr auto host_uid = 1, instance_uid = 2, host_gid = 3, instance_gid = 4; + constexpr auto mount_type = mp::VMMount::MountType::Native; + + QJsonArray mounts{}; + QJsonObject mount{}; + QJsonArray uid_mappings{}; + QJsonObject uid_mapping{}; + QJsonArray gid_mappings{}; + QJsonObject gid_mapping{}; + + uid_mapping["host_uid"] = host_uid; + uid_mapping["instance_uid"] = instance_uid; + uid_mappings.append(uid_mapping); + + gid_mapping["host_gid"] = host_gid; + gid_mapping["instance_gid"] = instance_gid; + gid_mappings.append(gid_mapping); + + mount["source_path"] = src_path; + mount["target_path"] = dst_path; + mount["uid_mappings"] = uid_mappings; + mount["gid_mappings"] = gid_mappings; + mount["mount_type"] = static_cast(mount_type); + + mounts.append(mount); + + auto json = test_snapshot_json(); + mod_snapshot_json(json, "mounts", mounts); + + auto snapshot = MockBaseSnapshot{plant_snapshot_json(json), vm}; + auto snapshot_mounts = snapshot.get_mounts(); + + ASSERT_THAT(snapshot_mounts, SizeIs(mounts.size())); + const auto [snapshot_mnt_dst, snapshot_mount] = *snapshot_mounts.begin(); + + EXPECT_EQ(snapshot_mnt_dst, dst_path); + EXPECT_EQ(snapshot_mount.source_path, src_path); + EXPECT_EQ(snapshot_mount.mount_type, mount_type); + + ASSERT_THAT(snapshot_mount.uid_mappings, SizeIs(uid_mappings.size())); + const auto [snapshot_host_uid, snapshot_instance_uid] = snapshot_mount.uid_mappings.front(); + + EXPECT_EQ(snapshot_host_uid, host_uid); + EXPECT_EQ(snapshot_instance_uid, instance_uid); + + ASSERT_THAT(snapshot_mount.gid_mappings, SizeIs(gid_mappings.size())); + const auto [snapshot_host_gid, snapshot_instance_gid] = snapshot_mount.gid_mappings.front(); + + EXPECT_EQ(snapshot_host_gid, host_gid); + EXPECT_EQ(snapshot_instance_gid, instance_gid); +} + +class TestSnapshotRejectedNonPositiveIndices : public TestBaseSnapshot, public WithParamInterface +{ +}; + +TEST_P(TestSnapshotRejectedNonPositiveIndices, refusesNonPositiveIndexFromJson) +{ + const auto index = GetParam(); + auto json = test_snapshot_json(); + mod_snapshot_json(json, "index", index); + + MP_EXPECT_THROW_THAT((MockBaseSnapshot{plant_snapshot_json(json), vm}), + std::runtime_error, + mpt::match_what(AllOf(HasSubstr("not positive"), HasSubstr(std::to_string(index))))); +} + +INSTANTIATE_TEST_SUITE_P(TestBaseSnapshot, TestSnapshotRejectedNonPositiveIndices, Values(0, -1, -31)); + +TEST_F(TestBaseSnapshot, refusesIndexAboveMax) +{ + constexpr auto index = 25623956; + auto json = test_snapshot_json(); + mod_snapshot_json(json, "index", index); + + MP_EXPECT_THROW_THAT((MockBaseSnapshot{plant_snapshot_json(json), vm}), + std::runtime_error, + mpt::match_what(AllOf(HasSubstr("Maximum"), HasSubstr(std::to_string(index))))); +} + +TEST_F(TestBaseSnapshot, setsName) +{ + constexpr auto new_name = "Murray"; + auto snapshot = MockBaseSnapshot{test_json_file_path, vm}; + + snapshot.set_name(new_name); + EXPECT_EQ(snapshot.get_name(), new_name); +} + +TEST_F(TestBaseSnapshot, setsComment) +{ + constexpr auto new_comment = "I once owned a dog that was smarter than you.\n" + "He must have taught you everything you know."; + auto snapshot = MockBaseSnapshot{test_json_file_path, vm}; + + snapshot.set_comment(new_comment); + EXPECT_EQ(snapshot.get_comment(), new_comment); +} + +TEST_F(TestBaseSnapshot, setsParent) +{ + auto child = MockBaseSnapshot{test_json_file_path, vm}; + auto parent = std::make_shared("parent", "", nullptr, specs, vm); + + child.set_parent(parent); + EXPECT_EQ(child.get_parent(), parent); +} + +class TestSnapshotPersistence : public TestBaseSnapshot, + public WithParamInterface> +{ +}; + +TEST_P(TestSnapshotPersistence, persistsOnEdition) +{ + constexpr auto index = 55; + auto setter = GetParam(); + + auto json = test_snapshot_json(); + mod_snapshot_json(json, "index", index); + + MockBaseSnapshot snapshot_orig{plant_snapshot_json(json), vm}; + setter(snapshot_orig); + + const auto file_path = derive_persisted_snapshot_file_path(index); + const MockBaseSnapshot snapshot_edited{file_path, vm}; + EXPECT_EQ(snapshot_edited, snapshot_orig); +} + +INSTANTIATE_TEST_SUITE_P(TestBaseSnapshot, + TestSnapshotPersistence, + Values([](MockBaseSnapshot& s) { s.set_name("asdf"); }, + [](MockBaseSnapshot& s) { s.set_comment("fdsa"); }, + [](MockBaseSnapshot& s) { s.set_parent(nullptr); })); + +TEST_F(TestBaseSnapshot, capturePersists) +{ + NiceMock snapshot{"Big Whoop", "treasure", nullptr, specs, vm}; + snapshot.capture(); + + const auto expected_file = QFileInfo{derive_persisted_snapshot_file_path(snapshot.get_index())}; + EXPECT_TRUE(expected_file.exists()); + EXPECT_TRUE(expected_file.isFile()); +} + +TEST_F(TestBaseSnapshot, captureCallsImpl) +{ + MockBaseSnapshot snapshot{"LeChuck", "'s Revenge", nullptr, specs, vm}; + EXPECT_CALL(snapshot, capture_impl).Times(1); + + snapshot.capture(); +} + +TEST_F(TestBaseSnapshot, applyCallsImpl) +{ + MockBaseSnapshot snapshot{"Guybrush", "fears porcelain", nullptr, specs, vm}; + EXPECT_CALL(snapshot, apply_impl).Times(1); + + snapshot.apply(); +} + +TEST_F(TestBaseSnapshot, eraseCallsImpl) +{ + NiceMock snapshot{"House of Mojo", "voodoo", nullptr, specs, vm}; + snapshot.capture(); + + EXPECT_CALL(snapshot, erase_impl).Times(1); + snapshot.erase(); +} + +TEST_F(TestBaseSnapshot, eraseRemovesFile) +{ + NiceMock snapshot{"House of Mojo", "voodoo", nullptr, specs, vm}; + snapshot.capture(); + + const auto expected_file_path = derive_persisted_snapshot_file_path(snapshot.get_index()); + ASSERT_TRUE(QFileInfo{expected_file_path}.exists()); + + snapshot.erase(); + EXPECT_FALSE(QFileInfo{expected_file_path}.exists()); +} + +TEST_F(TestBaseSnapshot, eraseThrowsIfUnableToRenameFile) +{ + NiceMock snapshot{"voodoo-sword", "Cursed Cutlass of Kaflu", nullptr, specs, vm}; + snapshot.capture(); + + auto [mock_file_ops, guard] = mpt::MockFileOps::inject(); + const auto expected_file_path = derive_persisted_snapshot_file_path(snapshot.get_index()); + EXPECT_CALL(*mock_file_ops, rename(Property(&QFile::fileName, Eq(expected_file_path)), _)).WillOnce(Return(false)); + + MP_EXPECT_THROW_THAT(snapshot.erase(), + std::runtime_error, + mpt::match_what(HasSubstr("Failed to move snapshot file"))); +} + +TEST_F(TestBaseSnapshot, restoresFileOnFailureToErase) +{ + NiceMock snapshot{"ultimate-insult", + "A powerful weapon capable of crippling even the toughest pirate's ego.", + nullptr, + specs, + vm}; + snapshot.capture(); + + const auto expected_file_path = derive_persisted_snapshot_file_path(snapshot.get_index()); + ASSERT_TRUE(QFileInfo{expected_file_path}.exists()); + + EXPECT_CALL(snapshot, erase_impl).WillOnce([&expected_file_path] { + ASSERT_FALSE(QFileInfo{expected_file_path}.exists()); + throw std::runtime_error{"test"}; + }); + + try + { + snapshot.erase(); + FAIL() << "shouldn't be here"; + } + catch (const std::runtime_error&) + { + EXPECT_TRUE(QFileInfo{expected_file_path}.exists()); + } +} + +TEST_F(TestBaseSnapshot, throwsIfUnableToOpenFile) +{ + auto [mock_file_ops, guard] = mpt::MockFileOps::inject(); + + EXPECT_CALL(*mock_file_ops, open(Property(&QFileDevice::fileName, Eq(test_json_file_path)), _)) + .WillOnce(Return(false)); + + MP_EXPECT_THROW_THAT( + (MockBaseSnapshot{test_json_file_path, vm}), + std::runtime_error, + mpt::match_what(AllOf(HasSubstr("Could not open"), HasSubstr(test_json_file_path.toStdString())))); +} + +TEST_F(TestBaseSnapshot, throwsOnEmptyJson) +{ + const auto snapshot_file_path = plant_snapshot_json(QJsonObject{}); + MP_EXPECT_THROW_THAT((MockBaseSnapshot{snapshot_file_path, vm}), + std::runtime_error, + mpt::match_what(HasSubstr("Empty"))); +} + +TEST_F(TestBaseSnapshot, throwsOnBadFormat) +{ + const auto snapshot_file_path = vm.tmp_dir->filePath("wrong"); + mpt::make_file_with_content( + snapshot_file_path, + "(Guybrush): Can I call you Bob?\n" + "\n" + "(Murray): You may call me Murray! I am a powerful demonic force! I'm the harbinger of your doom, and the " + "forces of darkness will applaude me as I stride through the gates of hell, carrying your head on a pike!\n" + "\n" + "(Guybrush): \"Stride\"?\n" + "\n" + "(Murray): Alright, then. ROLL! I shall ROLL through the gates of hell! Must you take the fun out of " + "everything?"); + + MP_EXPECT_THROW_THAT((MockBaseSnapshot{snapshot_file_path, vm}), + std::runtime_error, + mpt::match_what(HasSubstr("Could not parse snapshot JSON"))); +} + +TEST_F(TestBaseSnapshot, throwsOnMissingParent) +{ + EXPECT_CALL(vm, get_snapshot(An())).WillOnce(Throw(std::out_of_range{"Incognito"})); + MP_EXPECT_THROW_THAT((MockBaseSnapshot{test_json_file_path, vm}), + std::runtime_error, + mpt::match_what(HasSubstr("Missing snapshot parent"))); +} + +} // namespace diff --git a/tests/test_base_virtual_machine.cpp b/tests/test_base_virtual_machine.cpp index 917834ab10..affcd47f96 100644 --- a/tests/test_base_virtual_machine.cpp +++ b/tests/test_base_virtual_machine.cpp @@ -17,13 +17,25 @@ #include "common.h" #include "dummy_ssh_key_provider.h" +#include "file_operations.h" +#include "mock_logger.h" +#include "mock_snapshot.h" #include "mock_ssh_test_fixture.h" +#include "mock_utils.h" +#include "mock_virtual_machine.h" #include "temp_dir.h" #include +#include +#include #include +#include +#include #include +#include + +#include namespace mp = multipass; namespace mpl = multipass::logging; @@ -32,6 +44,52 @@ using namespace testing; namespace { +struct MockBaseVirtualMachine : public mpt::MockVirtualMachineT +{ + template + MockBaseVirtualMachine(Args&&... args) + : mpt::MockVirtualMachineT{std::forward(args)...} + { + auto& self = *this; + const auto& const_self = self; + MP_DELEGATE_MOCK_CALLS_ON_BASE(self, view_snapshots, mp::BaseVirtualMachine); + MP_DELEGATE_MOCK_CALLS_ON_BASE(self, get_num_snapshots, mp::BaseVirtualMachine); + MP_DELEGATE_MOCK_CALLS_ON_BASE(self, take_snapshot, mp::BaseVirtualMachine); + MP_DELEGATE_MOCK_CALLS_ON_BASE(self, rename_snapshot, mp::BaseVirtualMachine); + MP_DELEGATE_MOCK_CALLS_ON_BASE(self, delete_snapshot, mp::BaseVirtualMachine); + MP_DELEGATE_MOCK_CALLS_ON_BASE(self, restore_snapshot, mp::BaseVirtualMachine); + MP_DELEGATE_MOCK_CALLS_ON_BASE(self, load_snapshots, mp::BaseVirtualMachine); + MP_DELEGATE_MOCK_CALLS_ON_BASE(self, get_childrens_names, mp::BaseVirtualMachine); + MP_DELEGATE_MOCK_CALLS_ON_BASE(self, get_snapshot_count, mp::BaseVirtualMachine); + MP_DELEGATE_MOCK_CALLS_ON_BASE_WITH_MATCHERS(self, get_snapshot, mp::BaseVirtualMachine, (An())); + MP_DELEGATE_MOCK_CALLS_ON_BASE_WITH_MATCHERS(const_self, get_snapshot, mp::BaseVirtualMachine, (An())); + MP_DELEGATE_MOCK_CALLS_ON_BASE_WITH_MATCHERS(self, + get_snapshot, + mp::BaseVirtualMachine, + (A())); + MP_DELEGATE_MOCK_CALLS_ON_BASE_WITH_MATCHERS(const_self, + get_snapshot, + mp::BaseVirtualMachine, + (A())); + } + + MOCK_METHOD(void, require_snapshots_support, (), (const, override)); + MOCK_METHOD(std::shared_ptr, make_specific_snapshot, (const QString& filename), (override)); + MOCK_METHOD(std::shared_ptr, + make_specific_snapshot, + (const std::string& snapshot_name, + const std::string& comment, + const mp::VMSpecs& specs, + std::shared_ptr parent), + (override)); + + void simulate_no_snapshots_support() const // doing this here to access protected method on the base + { + auto& self = *this; + MP_DELEGATE_MOCK_CALLS_ON_BASE(self, require_snapshots_support, mp::BaseVirtualMachine); + } +}; + struct StubBaseVirtualMachine : public mp::BaseVirtualMachine { StubBaseVirtualMachine(mp::VirtualMachine::State s = mp::VirtualMachine::State::off) @@ -39,7 +97,7 @@ struct StubBaseVirtualMachine : public mp::BaseVirtualMachine { } - StubBaseVirtualMachine(mp::VirtualMachine::State s, std::unique_ptr&& tmp_dir) + StubBaseVirtualMachine(mp::VirtualMachine::State s, std::unique_ptr tmp_dir) : mp::BaseVirtualMachine{s, "stub", tmp_dir->path()}, tmp_dir{std::move(tmp_dir)} { } @@ -118,27 +176,74 @@ struct StubBaseVirtualMachine : public mp::BaseVirtualMachine { } -protected: - std::shared_ptr make_specific_snapshot(const std::string& /*snapshot_name*/, - const std::string& /*comment*/, - const mp::VMSpecs& /*specs*/, - std::shared_ptr /*parent*/) override + void require_snapshots_support() const override // pretend we support it here { - return nullptr; } - virtual std::shared_ptr make_specific_snapshot(const QString& /*json*/) override - { - return nullptr; - } - - std::unique_ptr&& tmp_dir; + std::unique_ptr tmp_dir; }; struct BaseVM : public Test { + void mock_snapshotting() + { + EXPECT_CALL(vm, make_specific_snapshot(_, _, _, _)) + .WillRepeatedly(WithArgs<0, 3>([this](const std::string& name, std::shared_ptr parent) { + auto ret = std::make_shared>(); + EXPECT_CALL(*ret, get_name).WillRepeatedly(Return(name)); + EXPECT_CALL(*ret, get_index).WillRepeatedly(Return(vm.get_snapshot_count() + 1)); + EXPECT_CALL(*ret, get_parent()).WillRepeatedly(Return(parent)); + EXPECT_CALL(Const(*ret), get_parent()).WillRepeatedly(Return(parent)); + EXPECT_CALL(*ret, get_parents_index).WillRepeatedly(Return(parent ? parent->get_index() : 0)); + + snapshot_album.push_back(ret); + + return ret; + })); + } + + QString get_snapshot_file_path(int idx) const + { + assert(idx > 0 && "need positive index"); + + return vm.tmp_dir->filePath(QString::fromStdString(fmt::format("{:04}.snapshot.json", idx))); + } + + static std::string n_occurrences(const std::string& regex, int n) + { + assert(n > 0 && "need positive n"); + return +#ifdef MULTIPASS_PLATFORM_WINDOWS + fmt::to_string(fmt::join(std::vector(n, regex), "")); +#else + fmt::format("{}{{{}}}", regex, n); +#endif + } + + static auto make_index_file_contents_matcher(int idx) + { + assert(idx > 0 && "need positive index"); + + return MatchesRegex(fmt::format("{0}*{1}{0}*", space_char_class, idx)); + } + mpt::MockSSHTestFixture mock_ssh_test_fixture; const mpt::DummyKeyProvider key_provider{"keeper of the seven keys"}; + NiceMock vm{"mock-vm"}; + std::vector> snapshot_album; + QString head_path = vm.tmp_dir->filePath(head_filename); + QString count_path = vm.tmp_dir->filePath(count_filename); + + static constexpr bool on_windows = +#ifdef MULTIPASS_PLATFORM_WINDOWS + true; +#else + false; +#endif + static constexpr auto* head_filename = "snapshot-head"; + static constexpr auto* count_filename = "snapshot-count"; + static constexpr auto space_char_class = on_windows ? "\\s" : "[[:space:]]"; + static constexpr auto digit_char_class = on_windows ? "\\d" : "[[:digit:]]"; }; TEST_F(BaseVM, get_all_ipv4_works_when_ssh_throws_opening_a_session) @@ -234,4 +339,839 @@ INSTANTIATE_TEST_SUITE_P( {"192.168.2.8", "192.168.3.1", "10.172.66.5"}}, IpTestParams{0, "", {}})); +TEST_F(BaseVM, startsWithNoSnapshots) +{ + EXPECT_EQ(vm.get_num_snapshots(), 0); +} + +TEST_F(BaseVM, throwsOnSnapshotsRequestIfNotSupported) +{ + vm.simulate_no_snapshots_support(); + MP_EXPECT_THROW_THAT(vm.get_num_snapshots(), + mp::NotImplementedOnThisBackendException, + mpt::match_what(HasSubstr("snapshots"))); +} + +TEST_F(BaseVM, takesSnapshots) +{ + auto snapshot = std::make_shared>(); + EXPECT_CALL(*snapshot, capture).Times(1); + + EXPECT_CALL(vm, make_specific_snapshot(_, _, _, _)).WillOnce(Return(snapshot)); + vm.take_snapshot(mp::VMSpecs{}, "s1", ""); + + EXPECT_EQ(vm.get_num_snapshots(), 1); +} + +TEST_F(BaseVM, takeSnasphotThrowsIfSpecificSnapshotNotOverridden) +{ + StubBaseVirtualMachine stub{}; + MP_EXPECT_THROW_THAT(stub.take_snapshot({}, "stub-snap", ""), + mp::NotImplementedOnThisBackendException, + mpt::match_what(HasSubstr("snapshots"))); +} + +TEST_F(BaseVM, deletesSnapshots) +{ + auto snapshot = std::make_shared>(); + EXPECT_CALL(*snapshot, erase).Times(1); + + EXPECT_CALL(vm, make_specific_snapshot(_, _, _, _)).WillOnce(Return(snapshot)); + vm.take_snapshot(mp::VMSpecs{}, "s1", ""); + vm.delete_snapshot("s1"); + + EXPECT_EQ(vm.get_num_snapshots(), 0); +} + +TEST_F(BaseVM, countsCurrentSnapshots) +{ + const mp::VMSpecs specs{}; + EXPECT_EQ(vm.get_num_snapshots(), 0); + + auto snapshot = std::make_shared>(); + EXPECT_CALL(vm, make_specific_snapshot(_, _, _, _)).WillRepeatedly(Return(snapshot)); + + vm.take_snapshot(specs, "s1", ""); + EXPECT_EQ(vm.get_num_snapshots(), 1); + + vm.take_snapshot(specs, "s2", ""); + vm.take_snapshot(specs, "s3", ""); + EXPECT_EQ(vm.get_num_snapshots(), 3); + + vm.delete_snapshot("s1"); + EXPECT_EQ(vm.get_num_snapshots(), 2); + + vm.delete_snapshot("s2"); + vm.delete_snapshot("s3"); + EXPECT_EQ(vm.get_num_snapshots(), 0); + + vm.take_snapshot(specs, "s4", ""); + EXPECT_EQ(vm.get_num_snapshots(), 1); +} + +TEST_F(BaseVM, countsTotalSnapshots) +{ + const mp::VMSpecs specs{}; + EXPECT_EQ(vm.get_num_snapshots(), 0); + + auto snapshot = std::make_shared>(); + EXPECT_CALL(vm, make_specific_snapshot(_, _, _, _)).WillRepeatedly(Return(snapshot)); + + vm.take_snapshot(specs, "s1", ""); + vm.take_snapshot(specs, "s2", ""); + vm.take_snapshot(specs, "s3", ""); + EXPECT_EQ(vm.get_snapshot_count(), 3); + + vm.take_snapshot(specs, "s4", ""); + vm.take_snapshot(specs, "s5", ""); + EXPECT_EQ(vm.get_snapshot_count(), 5); + + vm.delete_snapshot("s1"); + vm.delete_snapshot("s2"); + EXPECT_EQ(vm.get_snapshot_count(), 5); + + vm.delete_snapshot("s4"); + EXPECT_EQ(vm.get_snapshot_count(), 5); + + vm.take_snapshot(specs, "s6", ""); + EXPECT_EQ(vm.get_snapshot_count(), 6); +} + +TEST_F(BaseVM, providesSnapshotsView) +{ + mock_snapshotting(); + const mp::VMSpecs specs{}; + + auto sname = [](int i) { return fmt::format("s{}", i); }; + for (int i = 1; i < 6; ++i) // +5 + vm.take_snapshot(specs, sname(i), ""); + for (int i = 3; i < 5; ++i) // -2 + vm.delete_snapshot(sname(i)); + for (int i = 6; i < 9; ++i) // +3 + vm.take_snapshot(specs, sname(i), ""); + for (int i : {1, 7}) // -2 + vm.delete_snapshot(sname(i)); + + ASSERT_EQ(vm.get_num_snapshots(), 4); + auto snapshots = vm.view_snapshots(); + + EXPECT_THAT(snapshots, SizeIs(4)); + + std::vector snapshot_indices{}; + std::transform(snapshots.begin(), snapshots.end(), std::back_inserter(snapshot_indices), [](const auto& snapshot) { + return snapshot->get_index(); + }); + + EXPECT_THAT(snapshot_indices, UnorderedElementsAre(2, 5, 6, 8)); +} + +TEST_F(BaseVM, providesSnapshotsByIndex) +{ + mock_snapshotting(); + const mp::VMSpecs specs{}; + + vm.take_snapshot(specs, "foo", ""); + vm.take_snapshot(specs, "bar", "this and that"); + vm.delete_snapshot("foo"); + vm.take_snapshot(specs, "baz", "this and that"); + + for (const auto i : {2, 3}) + { + EXPECT_THAT(vm.get_snapshot(i), Pointee(Property(&mp::Snapshot::get_index, Eq(i)))); + } +} + +TEST_F(BaseVM, providesSnapshotsByName) +{ + mock_snapshotting(); + + const mp::VMSpecs specs{}; + const std::string target_name = "pick"; + vm.take_snapshot(specs, "foo", "irrelevant"); + vm.take_snapshot(specs, target_name, "fetch me"); + vm.take_snapshot(specs, "bar", "whatever"); + vm.take_snapshot(specs, "baz", ""); + vm.delete_snapshot("bar"); + vm.take_snapshot(specs, "asdf", ""); + + EXPECT_THAT(vm.get_snapshot(target_name), Pointee(Property(&mp::Snapshot::get_name, Eq(target_name)))); +} + +TEST_F(BaseVM, logsSnapshotHead) +{ + mock_snapshotting(); + const auto name = "asdf"; + + auto logger_scope = mpt::MockLogger::inject(mpl::Level::debug); + logger_scope.mock_logger->expect_log(mpl::Level::debug, name); + + vm.take_snapshot({}, name, ""); +} + +TEST_F(BaseVM, generatesSnapshotNameFromTotalCount) +{ + mock_snapshotting(); + + mp::VMSpecs specs{}; + for (int i = 1; i <= 5; ++i) + { + vm.take_snapshot(specs, "", ""); + EXPECT_EQ(vm.get_snapshot(i)->get_name(), fmt::format("snapshot{}", i)); + } +} + +TEST_F(BaseVM, throwsOnMissingSnapshotByIndex) +{ + mock_snapshotting(); + + auto expect_throw = [this](int i) { + MP_EXPECT_THROW_THAT(vm.get_snapshot(i), + std::runtime_error, + mpt::match_what(AllOf(HasSubstr(vm.vm_name), HasSubstr(std::to_string(i))))); + }; + + for (int i = -2; i < 4; ++i) + expect_throw(i); + + const mp::VMSpecs specs{}; + vm.take_snapshot(specs, "foo", "I know kung fu"); + vm.take_snapshot(specs, "bar", "blue pill"); + vm.take_snapshot(specs, "baz", "red pill"); + + for (int i : {-2, -1, 0, 4, 5, 100}) + expect_throw(i); +} + +TEST_F(BaseVM, throwsOnMissingSnapshotByName) +{ + mock_snapshotting(); + + auto expect_throws = [this]() { + std::array missing_names = {"neo", "morpheus", "trinity"}; + for (const auto& name : missing_names) + { + MP_EXPECT_THROW_THAT(vm.get_snapshot(name), + mp::NoSuchSnapshotException, + mpt::match_what(AllOf(HasSubstr(vm.vm_name), HasSubstr(name)))); + } + }; + + expect_throws(); + + const mp::VMSpecs specs{}; + vm.take_snapshot(specs, "smith", ""); + vm.take_snapshot(specs, "johnson", ""); + vm.take_snapshot(specs, "jones", ""); + + expect_throws(); +} + +TEST_F(BaseVM, throwsOnRepeatedSnapshotName) +{ + mock_snapshotting(); + + const mp::VMSpecs specs{}; + auto repeated_given_name = "asdf"; + auto repeated_derived_name = "snapshot3"; + vm.take_snapshot(specs, repeated_given_name, ""); + vm.take_snapshot(specs, repeated_derived_name, ""); + + MP_ASSERT_THROW_THAT(vm.take_snapshot(specs, repeated_given_name, ""), + mp::SnapshotNameTakenException, + mpt::match_what(HasSubstr(repeated_given_name))); + MP_EXPECT_THROW_THAT(vm.take_snapshot(specs, "", ""), // this would be the third snapshot + mp::SnapshotNameTakenException, + mpt::match_what(HasSubstr(repeated_derived_name))); +} + +TEST_F(BaseVM, snapshotDeletionUpdatesParents) +{ + mock_snapshotting(); + + const auto num_snapshots = 3; + const mp::VMSpecs specs{}; + for (int i = 0; i < num_snapshots; ++i) + vm.take_snapshot(specs, "", ""); + + ASSERT_EQ(snapshot_album.size(), num_snapshots); + + EXPECT_CALL(*snapshot_album[2], set_parent(Eq(snapshot_album[0]))).Times(1); + vm.delete_snapshot(snapshot_album[1]->get_name()); +} + +TEST_F(BaseVM, snapshotDeletionThrowsOnMissingSnapshot) +{ + const auto name = "missing"; + MP_EXPECT_THROW_THAT(vm.delete_snapshot(name), + mp::NoSuchSnapshotException, + mpt::match_what(AllOf(HasSubstr(vm.vm_name), HasSubstr(name)))); +} + +TEST_F(BaseVM, providesChildrenNames) +{ + mock_snapshotting(); + + const auto name_template = "s{}"; + const auto num_snapshots = 5; + const mp::VMSpecs specs{}; + for (int i = 0; i < num_snapshots; ++i) + vm.take_snapshot(specs, fmt::format(name_template, i), ""); + + ASSERT_EQ(snapshot_album.size(), num_snapshots); + + std::vector expected_children_names{}; + for (int i = 1; i < num_snapshots; ++i) + { + EXPECT_CALL(Const(*snapshot_album[i]), get_parent()).WillRepeatedly(Return(snapshot_album[0])); + expected_children_names.push_back(fmt::format(name_template, i)); + } + + EXPECT_THAT(vm.get_childrens_names(snapshot_album[0].get()), UnorderedElementsAreArray(expected_children_names)); + + for (int i = 1; i < num_snapshots; ++i) + { + EXPECT_THAT(vm.get_childrens_names(snapshot_album[i].get()), IsEmpty()); + } +} + +TEST_F(BaseVM, renamesSnapshot) +{ + const std::string old_name = "initial"; + const std::string new_name = "renamed"; + std::string current_name = old_name; + + auto snapshot = std::make_shared>(); + EXPECT_CALL(*snapshot, get_name()).WillRepeatedly(ReturnPointee(¤t_name)); + EXPECT_CALL(vm, make_specific_snapshot(_, _, _, _)).WillOnce(Return(snapshot)); + + vm.take_snapshot({}, old_name, "as ;lklkh afa"); + + EXPECT_CALL(*snapshot, set_name(Eq(new_name))).WillOnce(Assign(¤t_name, new_name)); + vm.rename_snapshot(old_name, new_name); + + EXPECT_EQ(vm.get_snapshot(new_name), snapshot); +} + +TEST_F(BaseVM, skipsSnapshotRenamingWithIdenticalName) +{ + mock_snapshotting(); + + const auto* name = "fixed"; + vm.take_snapshot({}, name, "not changing"); + + ASSERT_EQ(snapshot_album.size(), 1); + EXPECT_CALL(*snapshot_album[0], set_name).Times(0); + + EXPECT_NO_THROW(vm.rename_snapshot(name, name)); + EXPECT_EQ(vm.get_snapshot(name), snapshot_album[0]); +} + +TEST_F(BaseVM, throwsOnRequestToRenameMissingSnapshot) +{ + mock_snapshotting(); + + const auto* good_name = "Mafalda"; + const auto* missing_name = "Gui"; + vm.take_snapshot({}, good_name, ""); + + ASSERT_EQ(snapshot_album.size(), 1); + EXPECT_CALL(*snapshot_album[0], set_name).Times(0); + + MP_EXPECT_THROW_THAT(vm.rename_snapshot(missing_name, "Filipe"), + mp::NoSuchSnapshotException, + mpt::match_what(AllOf(HasSubstr(vm.vm_name), HasSubstr(missing_name)))); + + EXPECT_EQ(vm.get_snapshot(good_name), snapshot_album[0]); +} + +TEST_F(BaseVM, throwsOnRequestToRenameSnapshotWithRepeatedName) +{ + mock_snapshotting(); + + const auto names = std::array{"Mafalda", "Gui"}; + + mp::VMSpecs specs{}; + vm.take_snapshot(specs, names[0], ""); + vm.take_snapshot(specs, names[1], ""); + + ASSERT_EQ(snapshot_album.size(), 2); + EXPECT_CALL(*snapshot_album[0], set_name).Times(0); + + MP_EXPECT_THROW_THAT(vm.rename_snapshot(names[0], names[1]), + mp::SnapshotNameTakenException, + mpt::match_what(AllOf(HasSubstr(vm.vm_name), HasSubstr(names[1])))); + MP_EXPECT_THROW_THAT(vm.rename_snapshot(names[1], names[0]), + mp::SnapshotNameTakenException, + mpt::match_what(AllOf(HasSubstr(vm.vm_name), HasSubstr(names[0])))); + + EXPECT_EQ(vm.get_snapshot(names[0]), snapshot_album[0]); + EXPECT_EQ(vm.get_snapshot(names[1]), snapshot_album[1]); +} + +TEST_F(BaseVM, restoresSnapshots) +{ + mock_snapshotting(); + + mp::VMMount mount{"src", {}, {}, mp::VMMount::MountType::Classic}; + + QJsonObject metadata{}; + metadata["meta"] = "data"; + + const mp::VMSpecs original_specs{2, + mp::MemorySize{"3.5G"}, + mp::MemorySize{"15G"}, + "12:12:12:12:12:12", + {}, + "user", + mp::VirtualMachine::State::off, + {{"dst", mount}}, + false, + metadata, + {}}; + + const auto* snapshot_name = "shoot"; + vm.take_snapshot(original_specs, snapshot_name, ""); + + ASSERT_EQ(snapshot_album.size(), 1); + auto& snapshot = *snapshot_album[0]; + + mp::VMSpecs changed_specs = original_specs; + changed_specs.num_cores = 3; + changed_specs.mem_size = mp::MemorySize{"5G"}; + changed_specs.disk_space = mp::MemorySize{"35G"}; + changed_specs.state = mp::VirtualMachine::State::stopped; + changed_specs.mounts.clear(); + changed_specs.metadata["data"] = "meta"; + changed_specs.metadata["meta"] = "toto"; + + EXPECT_CALL(snapshot, apply); + EXPECT_CALL(snapshot, get_state).WillRepeatedly(Return(original_specs.state)); + EXPECT_CALL(snapshot, get_num_cores).WillRepeatedly(Return(original_specs.num_cores)); + EXPECT_CALL(snapshot, get_mem_size).WillRepeatedly(Return(original_specs.mem_size)); + EXPECT_CALL(snapshot, get_disk_space).WillRepeatedly(Return(original_specs.disk_space)); + EXPECT_CALL(snapshot, get_mounts).WillRepeatedly(ReturnRef(original_specs.mounts)); + EXPECT_CALL(snapshot, get_metadata).WillRepeatedly(ReturnRef(original_specs.metadata)); + + vm.restore_snapshot(snapshot_name, changed_specs); + + EXPECT_EQ(original_specs, changed_specs); +} + +TEST_F(BaseVM, usesRestoredSnapshotAsParentForNewSnapshots) +{ + mock_snapshotting(); + + mp::VMSpecs specs{}; + const std::string root_name{"first"}; + vm.take_snapshot(specs, root_name, ""); + auto root_snapshot = snapshot_album[0]; + + ASSERT_EQ(snapshot_album.size(), 1); + EXPECT_EQ(vm.take_snapshot(specs, "second", "")->get_parent(), root_snapshot); + ASSERT_EQ(snapshot_album.size(), 2); + EXPECT_EQ(vm.take_snapshot(specs, "third", "")->get_parent().get(), snapshot_album[1].get()); + + std::unordered_map mounts; + EXPECT_CALL(*root_snapshot, get_mounts).WillRepeatedly(ReturnRef(mounts)); + + QJsonObject metadata{}; + EXPECT_CALL(*root_snapshot, get_metadata).WillRepeatedly(ReturnRef(metadata)); + + vm.restore_snapshot(root_name, specs); + EXPECT_EQ(vm.take_snapshot(specs, "fourth", "")->get_parent(), root_snapshot); +} + +TEST_F(BaseVM, loadSnasphotThrowsIfSnapshotsNotImplemented) +{ + StubBaseVirtualMachine stub{}; + mpt::make_file_with_content(stub.tmp_dir->filePath("0001.snapshot.json"), "whatever-content"); + MP_EXPECT_THROW_THAT(stub.load_snapshots(), + mp::NotImplementedOnThisBackendException, + mpt::match_what(HasSubstr("snapshots"))); +} + +using SpacePadding = std::tuple; +struct TestLoadingOfPaddedGenericSnapshotInfo : public BaseVM, WithParamInterface +{ + void SetUp() override + { + static const auto space_matcher = MatchesRegex(fmt::format("{}*", space_char_class)); + ASSERT_THAT(padding_left, space_matcher); + ASSERT_THAT(padding_right, space_matcher); + } + + const std::string& padding_left = std::get<0>(GetParam()); + const std::string& padding_right = std::get<1>(GetParam()); +}; + +TEST_P(TestLoadingOfPaddedGenericSnapshotInfo, loadsAndUsesTotalSnapshotCount) +{ + mock_snapshotting(); + + int initial_count = 42; + auto count_text = fmt::format("{}{}{}", padding_left, initial_count, padding_right); + mpt::make_file_with_content(count_path, count_text); + + EXPECT_NO_THROW(vm.load_snapshots()); + + mp::VMSpecs specs{}; + for (int i = 1; i <= 5; ++i) + { + int expected_idx = initial_count + i; + vm.take_snapshot(specs, "", ""); + EXPECT_EQ(vm.get_snapshot(expected_idx)->get_name(), fmt::format("snapshot{}", expected_idx)); + } +} + +TEST_P(TestLoadingOfPaddedGenericSnapshotInfo, loadsAndUsesSnapshotHeadIndex) +{ + mock_snapshotting(); + + int head_index = 13; + auto snapshot = std::make_shared>(); + EXPECT_CALL(vm, get_snapshot(head_index)).WillOnce(Return(snapshot)); + + auto head_text = fmt::format("{}{}{}", padding_left, head_index, padding_right); + mpt::make_file_with_content(head_path, head_text); + mpt::make_file_with_content(count_path, "31"); + + EXPECT_NO_THROW(vm.load_snapshots()); + + auto name = "julius"; + vm.take_snapshot({}, name, ""); + EXPECT_EQ(vm.get_snapshot(name)->get_parent(), snapshot); +} + +std::vector space_paddings = {"", " ", " ", "\n", " \n", "\n\n\n", "\t", "\t\t\t", "\t \n \t "}; +INSTANTIATE_TEST_SUITE_P(BaseVM, + TestLoadingOfPaddedGenericSnapshotInfo, + Combine(ValuesIn(space_paddings), ValuesIn(space_paddings))); + +TEST_F(BaseVM, loadsSnasphots) +{ + static constexpr auto num_snapshots = 5; + static constexpr auto name_prefix = "blankpage"; + static constexpr auto generate_snapshot_name = [](int count) { return fmt::format("{}{}", name_prefix, count); }; + static const auto index_digits_regex = n_occurrences(digit_char_class, 4); + static const auto file_regex = fmt::format(R"(.*{}\.snapshot\.json)", index_digits_regex); + + auto& expectation = EXPECT_CALL(vm, make_specific_snapshot(mpt::match_qstring(MatchesRegex(file_regex)))); + + using NiceMockSnapshot = NiceMock; + std::array, num_snapshots> snapshot_bag{}; + generate(snapshot_bag.begin(), snapshot_bag.end(), [this, &expectation] { + static int idx = 1; + + mpt::make_file_with_content(get_snapshot_file_path(idx), "stub"); + + auto ret = std::make_shared(); + EXPECT_CALL(*ret, get_index).WillRepeatedly(Return(idx)); + EXPECT_CALL(*ret, get_name).WillRepeatedly(Return(generate_snapshot_name(idx++))); + expectation.WillOnce(Return(ret)); + + return ret; + }); + + mpt::make_file_with_content(head_path, fmt::format("{}", num_snapshots)); + mpt::make_file_with_content(count_path, fmt::format("{}", num_snapshots)); + + EXPECT_NO_THROW(vm.load_snapshots()); + + for (int i = 0; i < num_snapshots; ++i) + { + const auto idx = i + 1; + EXPECT_EQ(vm.get_snapshot(idx)->get_name(), generate_snapshot_name(idx)); + } +} + +TEST_F(BaseVM, throwsIfThereAreSnapshotsToLoadButNoGenericInfo) +{ + auto snapshot = std::make_shared>(); + + const auto name = "snapshot1"; + EXPECT_CALL(*snapshot, get_name).WillRepeatedly(Return(name)); + EXPECT_CALL(*snapshot, get_index).WillRepeatedly(Return(1)); + EXPECT_CALL(vm, make_specific_snapshot(_)).Times(2).WillRepeatedly(Return(snapshot)); + + mpt::make_file_with_content(get_snapshot_file_path(1), "stub"); + MP_EXPECT_THROW_THAT(vm.load_snapshots(), mp::FileOpenFailedException, mpt::match_what(HasSubstr(count_filename))); + + vm.delete_snapshot(name); + mpt::make_file_with_content(count_path, "1"); + MP_EXPECT_THROW_THAT(vm.load_snapshots(), mp::FileOpenFailedException, mpt::match_what(HasSubstr(head_filename))); +} + +TEST_F(BaseVM, throwsIfLoadedSnapshotsNameIsTaken) +{ + const auto common_name = "common"; + auto snapshot1 = std::make_shared>(); + auto snapshot2 = std::make_shared>(); + + EXPECT_CALL(*snapshot1, get_name).WillRepeatedly(Return(common_name)); + EXPECT_CALL(*snapshot1, get_index).WillRepeatedly(Return(1)); + + EXPECT_CALL(*snapshot2, get_name).WillRepeatedly(Return(common_name)); + EXPECT_CALL(*snapshot2, get_index).WillRepeatedly(Return(2)); + + EXPECT_CALL(vm, make_specific_snapshot(_)).WillOnce(Return(snapshot1)).WillOnce(Return(snapshot2)); + + mpt::make_file_with_content(get_snapshot_file_path(1), "stub"); + mpt::make_file_with_content(get_snapshot_file_path(2), "stub"); + mpt::make_file_with_content(head_path, "1"); + mpt::make_file_with_content(count_path, "2"); + + MP_EXPECT_THROW_THAT(vm.load_snapshots(), mp::SnapshotNameTakenException, mpt::match_what(HasSubstr(common_name))); +} + +TEST_F(BaseVM, snapshotDeletionRestoresParentsOnFailure) +{ + mock_snapshotting(); + + const auto num_snapshots = 3; + const mp::VMSpecs specs{}; + for (int i = 0; i < num_snapshots; ++i) + vm.take_snapshot(specs, "", ""); + + ASSERT_EQ(snapshot_album.size(), num_snapshots); + + EXPECT_CALL(*snapshot_album[2], set_parent(Eq(snapshot_album[0]))).Times(1); + EXPECT_CALL(*snapshot_album[2], set_parent(Eq(snapshot_album[1]))).Times(1); // rollback + + EXPECT_CALL(*snapshot_album[1], erase).WillOnce(Throw(std::runtime_error{"intentional"})); + EXPECT_ANY_THROW(vm.delete_snapshot(snapshot_album[1]->get_name())); +} + +TEST_F(BaseVM, snapshotDeletionKeepsHeadOnFailure) +{ + mock_snapshotting(); + + mp::VMSpecs specs{}; + vm.take_snapshot(specs, "", ""); + vm.take_snapshot(specs, "", ""); + + ASSERT_EQ(snapshot_album.size(), 2); + + EXPECT_CALL(*snapshot_album[1], erase).WillOnce(Throw(std::runtime_error{"intentional"})); + EXPECT_ANY_THROW(vm.delete_snapshot(snapshot_album[1]->get_name())); + + EXPECT_EQ(vm.take_snapshot(specs, "", "")->get_parent().get(), snapshot_album[1].get()); +} + +TEST_F(BaseVM, takeSnapshotRevertsToNullHeadOnFirstFailure) +{ + auto snapshot = std::make_shared>(); + EXPECT_CALL(*snapshot, capture).WillOnce(Throw(std::runtime_error{"intentional"})); + EXPECT_CALL(vm, make_specific_snapshot(_, _, _, _)).WillOnce(Return(snapshot)).RetiresOnSaturation(); + + mp::VMSpecs specs{}; + EXPECT_ANY_THROW(vm.take_snapshot(specs, "", "")); + + mock_snapshotting(); + EXPECT_EQ(vm.take_snapshot(specs, "", "")->get_parent().get(), nullptr); +} + +TEST_F(BaseVM, takeSnapshotRevertsHeadAndCount) +{ + auto early_snapshot = std::make_shared>(); + EXPECT_CALL(*early_snapshot, get_name).WillRepeatedly(Return("asdf")); + EXPECT_CALL(*early_snapshot, get_index).WillRepeatedly(Return(1)); + + EXPECT_CALL(vm, make_specific_snapshot(_)).WillOnce(Return(early_snapshot)); + + mpt::make_file_with_content(get_snapshot_file_path(1), "stub"); + mpt::make_file_with_content(head_path, "1"); + mpt::make_file_with_content(count_path, "1"); + + vm.load_snapshots(); + + constexpr auto attempted_name = "fdsa"; + auto failing_snapshot = std::make_shared>(); + + EXPECT_CALL(*failing_snapshot, get_name).WillRepeatedly(Return(attempted_name)); + EXPECT_CALL(*failing_snapshot, get_index).WillRepeatedly(Return(2)); + EXPECT_CALL(*failing_snapshot, get_parents_index) + .WillOnce(Throw(std::runtime_error{"intentional"})) // causes persisting to break, after successful capture + .RetiresOnSaturation(); + + EXPECT_CALL(vm, make_specific_snapshot(_, _, _, _)).WillOnce(Return(failing_snapshot)).RetiresOnSaturation(); + + mp::VMSpecs specs{}; + EXPECT_ANY_THROW(vm.take_snapshot(specs, attempted_name, "")); + + mock_snapshotting(); + auto new_snapshot = vm.take_snapshot(specs, attempted_name, ""); + EXPECT_EQ(new_snapshot->get_parent(), early_snapshot); + EXPECT_EQ(new_snapshot->get_index(), 2); // snapshot count not increased by failed snapshot +} + +TEST_F(BaseVM, renameFailureIsReverted) +{ + std::string current_name = "before"; + std::string attempted_name = "after"; + auto snapshot = std::make_shared>(); + EXPECT_CALL(*snapshot, get_name()).WillRepeatedly(Return(current_name)); + EXPECT_CALL(vm, make_specific_snapshot(_, _, _, _)).WillOnce(Return(snapshot)); + + vm.take_snapshot({}, current_name, ""); + + EXPECT_CALL(*snapshot, set_name(Eq(attempted_name))).WillOnce(Throw(std::runtime_error{"intentional"})); + EXPECT_ANY_THROW(vm.rename_snapshot(current_name, attempted_name)); + + EXPECT_EQ(vm.get_snapshot(current_name), snapshot); +} + +TEST_F(BaseVM, persistsGenericSnapshotInfoWhenTakingSnapshot) +{ + mock_snapshotting(); + + ASSERT_EQ(vm.get_snapshot_count(), 0); + + ASSERT_FALSE(QFileInfo{head_path}.exists()); + ASSERT_FALSE(QFileInfo{count_path}.exists()); + + mp::VMSpecs specs{}; + for (int i = 1; i < 5; ++i) + { + vm.take_snapshot(specs, "", ""); + ASSERT_TRUE(QFileInfo{head_path}.exists()); + ASSERT_TRUE(QFileInfo{count_path}.exists()); + + auto regex_matcher = make_index_file_contents_matcher(i); + EXPECT_THAT(mpt::load(head_path).toStdString(), regex_matcher); + EXPECT_THAT(mpt::load(count_path).toStdString(), regex_matcher); + } +} + +TEST_F(BaseVM, removesGenericSnapshotInfoFilesOnFirstFailure) +{ + auto [mock_utils_ptr, guard] = mpt::MockUtils::inject(); + auto& mock_utils = *mock_utils_ptr; + mock_snapshotting(); + + ASSERT_FALSE(QFileInfo{head_path}.exists()); + ASSERT_FALSE(QFileInfo{count_path}.exists()); + + MP_DELEGATE_MOCK_CALLS_ON_BASE_WITH_MATCHERS(mock_utils, + make_file_with_content, + mp::Utils, + (EndsWith(head_filename), _, Eq(true))); + EXPECT_CALL(mock_utils, make_file_with_content(EndsWith(head_filename), _, Eq(true))); + EXPECT_CALL(mock_utils, make_file_with_content(EndsWith(count_filename), _, Eq(true))) + .WillOnce(Throw(std::runtime_error{"intentional"})); + + EXPECT_ANY_THROW(vm.take_snapshot({}, "", "")); + + EXPECT_FALSE(QFileInfo{head_path}.exists()); + EXPECT_FALSE(QFileInfo{count_path}.exists()); +} + +TEST_F(BaseVM, restoresGenericSnapshotInfoFileContents) +{ + mock_snapshotting(); + + mp::VMSpecs specs{}; + vm.take_snapshot(specs, "", ""); + + ASSERT_TRUE(QFileInfo{head_path}.exists()); + ASSERT_TRUE(QFileInfo{count_path}.exists()); + + auto regex_matcher = make_index_file_contents_matcher(1); + EXPECT_THAT(mpt::load(head_path).toStdString(), regex_matcher); + EXPECT_THAT(mpt::load(count_path).toStdString(), regex_matcher); + + auto [mock_utils_ptr, guard] = mpt::MockUtils::inject(); + auto& mock_utils = *mock_utils_ptr; + + MP_DELEGATE_MOCK_CALLS_ON_BASE_WITH_MATCHERS(mock_utils, make_file_with_content, mp::Utils, (_, _, Eq(true))); + EXPECT_CALL(mock_utils, make_file_with_content(EndsWith(head_filename), _, Eq(true))).Times(2); + EXPECT_CALL(mock_utils, make_file_with_content(EndsWith(count_filename), _, Eq(true))) + .WillOnce(Throw(std::runtime_error{"intentional"})) + .WillOnce(DoDefault()); + + EXPECT_ANY_THROW(vm.take_snapshot({}, "", "")); + + EXPECT_TRUE(QFileInfo{head_path}.exists()); + EXPECT_TRUE(QFileInfo{count_path}.exists()); + EXPECT_THAT(mpt::load(head_path).toStdString(), regex_matcher); + EXPECT_THAT(mpt::load(count_path).toStdString(), regex_matcher); +} + +TEST_F(BaseVM, persistsHeadIndexOnRestore) +{ + mock_snapshotting(); + + mp::VMSpecs specs{}; + const auto intended_snapshot = "this-one"; + vm.take_snapshot(specs, "foo", ""); + vm.take_snapshot(specs, intended_snapshot, ""); + vm.take_snapshot(specs, "bar", ""); + + std::unordered_map mounts; + EXPECT_CALL(*snapshot_album[1], get_mounts).WillRepeatedly(ReturnRef(mounts)); + + QJsonObject metadata{}; + EXPECT_CALL(*snapshot_album[1], get_metadata).WillRepeatedly(ReturnRef(metadata)); + + vm.restore_snapshot(intended_snapshot, specs); + EXPECT_TRUE(QFileInfo{head_path}.exists()); + + auto regex_matcher = make_index_file_contents_matcher(snapshot_album[1]->get_index()); + EXPECT_THAT(mpt::load(head_path).toStdString(), regex_matcher); +} + +TEST_F(BaseVM, rollsbackFailedRestore) +{ + mock_snapshotting(); + + const mp::VMSpecs original_specs{1, + mp::MemorySize{"1.5G"}, + mp::MemorySize{"4G"}, + "ab:ab:ab:ab:ab:ab", + {}, + "me", + mp::VirtualMachine::State::off, + {}, + false, + {}, + {}}; + + vm.take_snapshot(original_specs, "", ""); + + auto target_snapshot_name = "this one"; + vm.take_snapshot(original_specs, target_snapshot_name, ""); + vm.take_snapshot(original_specs, "", ""); + + ASSERT_EQ(snapshot_album.size(), 3); + auto& target_snapshot = *snapshot_album[1]; + auto& last_snapshot = *snapshot_album[2]; + + auto changed_specs = original_specs; + changed_specs.num_cores = 4; + changed_specs.mem_size = mp::MemorySize{"2G"}; + changed_specs.state = multipass::VirtualMachine::State::running; + changed_specs.mounts["dst"].source_path = "src"; + changed_specs.metadata["blah"] = "this and that"; + + EXPECT_CALL(target_snapshot, get_state).WillRepeatedly(Return(original_specs.state)); + EXPECT_CALL(target_snapshot, get_num_cores).WillRepeatedly(Return(original_specs.num_cores)); + EXPECT_CALL(target_snapshot, get_mem_size).WillRepeatedly(Return(original_specs.mem_size)); + EXPECT_CALL(target_snapshot, get_disk_space).WillRepeatedly(Return(original_specs.disk_space)); + EXPECT_CALL(target_snapshot, get_mounts).WillRepeatedly(ReturnRef(original_specs.mounts)); + EXPECT_CALL(target_snapshot, get_metadata).WillRepeatedly(ReturnRef(original_specs.metadata)); + + auto [mock_utils_ptr, guard] = mpt::MockUtils::inject(); + EXPECT_CALL(*mock_utils_ptr, make_file_with_content(_, _, _)) + .WillOnce(Throw(std::runtime_error{"intentional"})) + .WillRepeatedly(DoDefault()); + + auto current_specs = changed_specs; + EXPECT_ANY_THROW(vm.restore_snapshot(target_snapshot_name, current_specs)); + EXPECT_EQ(changed_specs, current_specs); + + auto regex_matcher = make_index_file_contents_matcher(last_snapshot.get_index()); + EXPECT_THAT(mpt::load(head_path).toStdString(), regex_matcher); + + EXPECT_EQ(vm.take_snapshot(current_specs, "", "")->get_parent().get(), &last_snapshot); +} + } // namespace diff --git a/tests/test_data/test_snapshot.json b/tests/test_data/test_snapshot.json new file mode 100644 index 0000000000..fc8a889895 --- /dev/null +++ b/tests/test_data/test_snapshot.json @@ -0,0 +1,82 @@ +{ + "snapshot": { + "comment": "A comment", + "creation_timestamp": "2023-11-15T12:35:13.585Z", + "disk_space": "5368709120", + "index": 3, + "mem_size": "1073741824", + "metadata": { + "arguments": [ + "-bios", + "OVMF.fd", + "--enable-kvm", + "-cpu", + "host", + "-nic", + "tap,ifname=tap-a3a4a846832,script=no,downscript=no,model=virtio-net-pci,mac=52:54:00:2a:84:b9", + "-device", + "virtio-scsi-pci,id=scsi0", + "-drive", + "file=/var/snap/multipass/common/data/multipassd/vault/instances/phlegmatic-orca/ubuntu-22.04-server-cloudimg-amd64.img,if=none,format=qcow2,discard=unmap,id=hda", + "-device", + "scsi-hd,drive=hda,bus=scsi0.0", + "-smp", + "1", + "-m", + "1024M", + "-qmp", + "stdio", + "-chardev", + "null,id=char0", + "-serial", + "chardev:char0", + "-nographic", + "-cdrom", + "/var/snap/multipass/common/data/multipassd/vault/instances/phlegmatic-orca/cloud-init-config.iso" + ], + "machine_type": "pc-i440fx-8.0", + "mount_data": { + } + }, + "mounts": [ + { + "gid_mappings": [ + { + "host_gid": 1000, + "instance_gid": -1 + } + ], + "mount_type": 1, + "source_path": "/home/talking/skull", + "target_path": "murray", + "uid_mappings": [ + { + "host_uid": 1000, + "instance_uid": -1 + } + ] + }, + { + "gid_mappings": [ + { + "host_gid": 1000, + "instance_gid": -1 + } + ], + "mount_type": 0, + "source_path": "/home/mighty/pirate", + "target_path": "guybrush", + "uid_mappings": [ + { + "host_uid": 1000, + "instance_uid": -1 + } + ] + } + ], + "name": "snapshot3", + "num_cores": 1, + "parent": 2, + "state": 0 + } +} diff --git a/tests/test_json_utils.cpp b/tests/test_json_utils.cpp new file mode 100644 index 0000000000..e33b44add7 --- /dev/null +++ b/tests/test_json_utils.cpp @@ -0,0 +1,100 @@ +/* + * Copyright (C) Canonical, Ltd. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "common.h" +#include "mock_file_ops.h" + +#include + +#include +#include +#include +#include +#include + +#include + +namespace mpt = multipass::test; +using namespace testing; + +namespace +{ +struct TestJsonUtils : public Test +{ + mpt::MockFileOps::GuardedMock guarded_mock_file_ops = mpt::MockFileOps::inject(); + mpt::MockFileOps& mock_file_ops = *guarded_mock_file_ops.first; + inline static const QChar separator = QDir::separator(); + inline static const QString dir = QStringLiteral("a%1b%1c").arg(separator); + inline static const QString file_name = QStringLiteral("asd.blag"); + inline static const QString file_path = QStringLiteral("%1%2%3").arg(dir, separator, file_name); + inline static const char* json_text = R"({"a": [1,2,3]})"; + inline static const QJsonObject json = QJsonDocument::fromJson(json_text).object(); + template + inline static Matcher file_matcher = Property(&T::fileName, Eq(file_path)); +}; + +TEST_F(TestJsonUtils, writesJsonTransactionally) +{ + auto json_matcher = + ResultOf([](auto&& text) { return QJsonDocument::fromJson(std::forward(text), nullptr); }, + Property(&QJsonDocument::object, Eq(json))); + EXPECT_CALL(mock_file_ops, mkpath(Eq(dir), Eq("."))).WillOnce(Return(true)); + EXPECT_CALL(mock_file_ops, open(file_matcher, _)).WillOnce(Return(true)); + EXPECT_CALL(mock_file_ops, write(file_matcher, json_matcher)).WillOnce(Return(14)); + EXPECT_CALL(mock_file_ops, commit(file_matcher)).WillOnce(Return(true)); + EXPECT_NO_THROW(MP_JSONUTILS.write_json(json, file_path)); +} + +TEST_F(TestJsonUtils, writeJsonThrowsOnFailureToCreateDirectory) +{ + EXPECT_CALL(mock_file_ops, mkpath).WillOnce(Return(false)); + MP_EXPECT_THROW_THAT(MP_JSONUTILS.write_json(json, file_path), + std::runtime_error, + mpt::match_what(AllOf(HasSubstr("Could not create"), HasSubstr(dir.toStdString())))); +} + +TEST_F(TestJsonUtils, writeJsonThrowsOnFailureToOpenFile) +{ + EXPECT_CALL(mock_file_ops, mkpath).WillOnce(Return(true)); + EXPECT_CALL(mock_file_ops, open(_, _)).WillOnce(Return(false)); + MP_EXPECT_THROW_THAT(MP_JSONUTILS.write_json(json, file_path), + std::runtime_error, + mpt::match_what(AllOf(HasSubstr("Could not open"), HasSubstr(file_path.toStdString())))); +} + +TEST_F(TestJsonUtils, writeJsonThrowsOnFailureToWriteFile) +{ + EXPECT_CALL(mock_file_ops, mkpath).WillOnce(Return(true)); + EXPECT_CALL(mock_file_ops, open(_, _)).WillOnce(Return(true)); + EXPECT_CALL(mock_file_ops, write(_, _)).WillOnce(Return(-1)); + MP_EXPECT_THROW_THAT(MP_JSONUTILS.write_json(json, file_path), + std::runtime_error, + mpt::match_what(AllOf(HasSubstr("Could not write"), HasSubstr(file_path.toStdString())))); +} + +TEST_F(TestJsonUtils, writeJsonThrowsOnFailureToCommit) +{ + EXPECT_CALL(mock_file_ops, mkpath).WillOnce(Return(true)); + EXPECT_CALL(mock_file_ops, open(_, _)).WillOnce(Return(true)); + EXPECT_CALL(mock_file_ops, write(_, _)).WillOnce(Return(1234)); + EXPECT_CALL(mock_file_ops, commit).WillOnce(Return(false)); + MP_EXPECT_THROW_THAT(MP_JSONUTILS.write_json(json, file_path), + std::runtime_error, + mpt::match_what(AllOf(HasSubstr("Could not commit"), HasSubstr(file_path.toStdString())))); +} + +} // namespace diff --git a/tests/test_qemu_snapshot.cpp b/tests/test_qemu_snapshot.cpp new file mode 100644 index 0000000000..7794ba7e8b --- /dev/null +++ b/tests/test_qemu_snapshot.cpp @@ -0,0 +1,306 @@ +/* + * Copyright (C) Canonical, Ltd. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "common.h" +#include "mock_logger.h" +#include "mock_process_factory.h" +#include "mock_snapshot.h" +#include "mock_virtual_machine.h" +#include "path.h" + +#include +#include +#include +#include + +#include +#include + +#include +#include + +namespace mp = multipass; +namespace mpt = multipass::test; +using namespace testing; + +namespace +{ + +struct PublicQemuSnapshot : public mp::QemuSnapshot +{ + // clang-format off + // (keeping original declaration order) + using mp::QemuSnapshot::QemuSnapshot; + using mp::QemuSnapshot::capture_impl; + using mp::QemuSnapshot::erase_impl; + using mp::QemuSnapshot::apply_impl; + // clang-format on +}; + +struct TestQemuSnapshot : public Test +{ + using ArgsMatcher = Matcher; + + mp::QemuSnapshot quick_snapshot(const std::string& name = "asdf") + { + return mp::QemuSnapshot{name, "", nullptr, specs, vm, desc}; + } + + mp::QemuSnapshot loaded_snapshot() + { + return mp::QemuSnapshot{mpt::test_data_path_for("test_snapshot.json"), vm, desc}; + } + + template + static std::string derive_tag(T&& index) + { + return fmt::format("@s{}", std::forward(index)); + } + + static void set_common_expectations_on(mpt::MockProcess* process) + { + EXPECT_EQ(process->program(), "qemu-img"); + EXPECT_CALL(*process, execute).WillOnce(Return(success)); + } + + static void set_tag_output(mpt::MockProcess* process, std::string tag) + { + EXPECT_CALL(*process, read_all_standard_output).WillOnce(Return(QByteArray::fromStdString(tag + ' '))); + } + + mp::VirtualMachineDescription desc = [] { + mp::VirtualMachineDescription ret{}; + ret.image.image_path = "raniunotuiroleh"; + return ret; + }(); + + NiceMock> vm{"qemu-vm"}; + ArgsMatcher list_args_matcher = ElementsAre("snapshot", "-l", desc.image.image_path); + + inline static const auto success = mp::ProcessState{0, std::nullopt}; + inline static const auto failure = mp::ProcessState{1, std::nullopt}; + inline static const auto specs = [] { + const auto cpus = 3; + const auto mem_size = mp::MemorySize{"1.23G"}; + const auto disk_space = mp::MemorySize{"3.21M"}; + const auto state = mp::VirtualMachine::State::off; + const auto mounts = + std::unordered_map{{"asdf", {"fdsa", {}, {}, mp::VMMount::MountType::Classic}}}; + const auto metadata = [] { + auto metadata = QJsonObject{}; + metadata["meta"] = "data"; + return metadata; + }(); + + return mp::VMSpecs{cpus, mem_size, disk_space, "mac", {}, "", state, mounts, false, metadata, {}}; + }(); +}; + +TEST_F(TestQemuSnapshot, initializesBaseProperties) +{ + const auto name = "name"; + const auto comment = "comment"; + const auto parent = std::make_shared(); + + auto desc = mp::VirtualMachineDescription{}; + auto vm = NiceMock>{"qemu-vm"}; + + const auto snapshot = mp::QemuSnapshot{name, comment, parent, specs, vm, desc}; + EXPECT_EQ(snapshot.get_name(), name); + EXPECT_EQ(snapshot.get_comment(), comment); + EXPECT_EQ(snapshot.get_parent(), parent); + EXPECT_EQ(snapshot.get_num_cores(), specs.num_cores); + EXPECT_EQ(snapshot.get_mem_size(), specs.mem_size); + EXPECT_EQ(snapshot.get_disk_space(), specs.disk_space); + EXPECT_EQ(snapshot.get_state(), specs.state); + EXPECT_EQ(snapshot.get_mounts(), specs.mounts); + EXPECT_EQ(snapshot.get_metadata(), specs.metadata); +} + +TEST_F(TestQemuSnapshot, initializesBasePropertiesFromJson) +{ + const auto parent = std::make_shared(); + EXPECT_CALL(vm, get_snapshot(2)).WillOnce(Return(parent)); + + const mp::QemuSnapshot snapshot{mpt::test_data_path_for("test_snapshot.json"), vm, desc}; + EXPECT_EQ(snapshot.get_name(), "snapshot3"); + EXPECT_EQ(snapshot.get_comment(), "A comment"); + EXPECT_EQ(snapshot.get_parent(), parent); + EXPECT_EQ(snapshot.get_num_cores(), 1); + EXPECT_EQ(snapshot.get_mem_size(), mp::MemorySize{"1G"}); + EXPECT_EQ(snapshot.get_disk_space(), mp::MemorySize{"5G"}); + EXPECT_EQ(snapshot.get_state(), mp::VirtualMachine::State::off); + + auto mount_matcher1 = Pair(Eq("guybrush"), Field(&mp::VMMount::mount_type, mp::VMMount::MountType::Classic)); + auto mount_matcher2 = Pair(Eq("murray"), Field(&mp::VMMount::mount_type, mp::VMMount::MountType::Native)); + EXPECT_THAT(snapshot.get_mounts(), UnorderedElementsAre(mount_matcher1, mount_matcher2)); + + EXPECT_THAT( + snapshot.get_metadata(), + ResultOf([](const QJsonObject& metadata) { return metadata["arguments"].toArray(); }, Contains("-qmp"))); +} + +TEST_F(TestQemuSnapshot, capturesSnapshot) +{ + auto snapshot_index = 3; + auto snapshot_tag = derive_tag(snapshot_index); + EXPECT_CALL(vm, get_snapshot_count).WillOnce(Return(snapshot_index - 1)); + + auto proc_count = 0; + + ArgsMatcher capture_args_matcher{ + ElementsAre("snapshot", "-c", QString::fromStdString(snapshot_tag), desc.image.image_path)}; + + auto mock_factory_scope = mpt::MockProcessFactory::Inject(); + mock_factory_scope->register_callback([&](mpt::MockProcess* process) { + ASSERT_LE(++proc_count, 2); + + set_common_expectations_on(process); + + const auto& args_matcher = proc_count == 1 ? list_args_matcher : capture_args_matcher; + EXPECT_THAT(process->arguments(), args_matcher); + }); + + quick_snapshot().capture(); + EXPECT_EQ(proc_count, 2); +} + +TEST_F(TestQemuSnapshot, captureThrowsOnRepeatedTag) +{ + auto snapshot_index = 22; + auto snapshot_tag = derive_tag(snapshot_index); + EXPECT_CALL(vm, get_snapshot_count).WillOnce(Return(snapshot_index - 1)); + + auto proc_count = 0; + auto mock_factory_scope = mpt::MockProcessFactory::Inject(); + mock_factory_scope->register_callback([&](mpt::MockProcess* process) { + ASSERT_EQ(++proc_count, 1); + + set_common_expectations_on(process); + + EXPECT_THAT(process->arguments(), list_args_matcher); + set_tag_output(process, snapshot_tag); + }); + + MP_EXPECT_THROW_THAT(quick_snapshot("whatever").capture(), + std::runtime_error, + mpt::match_what(AllOf(HasSubstr("already exists"), + HasSubstr(snapshot_tag), + HasSubstr(desc.image.image_path.toStdString())))); +} + +TEST_F(TestQemuSnapshot, erasesSnapshot) +{ + auto snapshot = loaded_snapshot(); + auto proc_count = 0; + + auto mock_factory_scope = mpt::MockProcessFactory::Inject(); + mock_factory_scope->register_callback([&](mpt::MockProcess* process) { + ASSERT_LE(++proc_count, 2); + + set_common_expectations_on(process); + + auto tag = derive_tag(snapshot.get_index()); + if (proc_count == 1) + { + EXPECT_THAT(process->arguments(), list_args_matcher); + set_tag_output(process, tag); + } + else + { + EXPECT_THAT(process->arguments(), + ElementsAre("snapshot", "-d", QString::fromStdString(tag), desc.image.image_path)); + } + }); + + snapshot.erase(); + EXPECT_EQ(proc_count, 2); +} + +TEST_F(TestQemuSnapshot, eraseLogsOnMissingTag) +{ + auto snapshot = loaded_snapshot(); + auto proc_count = 0; + + auto mock_factory_scope = mpt::MockProcessFactory::Inject(); + mock_factory_scope->register_callback([&](mpt::MockProcess* process) { + ASSERT_EQ(++proc_count, 1); + + set_common_expectations_on(process); + EXPECT_THAT(process->arguments(), list_args_matcher); + set_tag_output(process, "some-tag-other-than-the-one-we-are-looking-for"); + }); + + auto expected_log_level = mpl::Level::warning; + auto logger_scope = mpt::MockLogger::inject(expected_log_level); + logger_scope.mock_logger->expect_log(expected_log_level, "Could not find"); + + snapshot.erase(); +} + +TEST_F(TestQemuSnapshot, appliesSnapshot) +{ + auto snapshot = loaded_snapshot(); + auto proc_count = 0; + + auto mock_factory_scope = mpt::MockProcessFactory::Inject(); + mock_factory_scope->register_callback([&](mpt::MockProcess* process) { + ASSERT_EQ(++proc_count, 1); + + set_common_expectations_on(process); + EXPECT_THAT(process->arguments(), + ElementsAre("snapshot", + "-a", + QString::fromStdString(derive_tag(snapshot.get_index())), + desc.image.image_path)); + }); + + desc.num_cores = 8598; + desc.mem_size = mp::MemorySize{"49"}; + desc.disk_space = mp::MemorySize{"328"}; + + snapshot.apply(); + + EXPECT_EQ(desc.num_cores, snapshot.get_num_cores()); + EXPECT_EQ(desc.mem_size, snapshot.get_mem_size()); + EXPECT_EQ(desc.disk_space, snapshot.get_disk_space()); +} + +TEST_F(TestQemuSnapshot, keepsDescOnFailure) +{ + auto snapshot = loaded_snapshot(); + auto proc_count = 0; + + auto mock_factory_scope = mpt::MockProcessFactory::Inject(); + mock_factory_scope->register_callback([&](mpt::MockProcess* process) { + ASSERT_EQ(++proc_count, 1); + EXPECT_CALL(*process, execute).WillOnce(Return(failure)); + }); + + desc.num_cores = 123; + desc.mem_size = mp::MemorySize{"321"}; + desc.disk_space = mp::MemorySize{"56K"}; + + const auto orig_desc = desc; + MP_EXPECT_THROW_THAT(snapshot.apply(), std::runtime_error, mpt::match_what(HasSubstr("qemu-img failed"))); + + EXPECT_EQ(orig_desc.num_cores, desc.num_cores); + EXPECT_EQ(orig_desc.mem_size, desc.mem_size); + EXPECT_EQ(orig_desc.disk_space, desc.disk_space); +} + +} // namespace diff --git a/tests/test_utils.cpp b/tests/test_utils.cpp index 4bb28b32cb..a77b8232f1 100644 --- a/tests/test_utils.cpp +++ b/tests/test_utils.cpp @@ -390,12 +390,37 @@ TEST(Utils, to_cmd_arguments_with_double_quotes_are_escaped) EXPECT_THAT(output, ::testing::StrEq("they said \\\"please\\\"")); } -TEST(Utils, trim_end_actually_trims_end) +struct TestTrimUtilities : public Test +{ + std::string s{"\n \f \n \r \t \vI'm a great\n\t string \n \f \n \r \t \v"}; +}; + +TEST_F(TestTrimUtilities, trimEndActuallyTrimsEnd) { - std::string s{"I'm a great\n\t string \n \f \n \r \t \v"}; mp::utils::trim_end(s); - EXPECT_THAT(s, ::testing::StrEq("I'm a great\n\t string")); + EXPECT_THAT(s, ::testing::StrEq("\n \f \n \r \t \vI'm a great\n\t string")); +} + +TEST_F(TestTrimUtilities, trimBeginActuallyTrimsTheBeginning) +{ + mp::utils::trim_begin(s); + + EXPECT_EQ(s, "I'm a great\n\t string \n \f \n \r \t \v"); +} + +TEST_F(TestTrimUtilities, trimActuallyTrims) +{ + mp::utils::trim(s); + + EXPECT_EQ(s, "I'm a great\n\t string"); +} + +TEST_F(TestTrimUtilities, trimAcceptsCustomFilter) +{ + mp::utils::trim(s, [](unsigned char c) { return c == '\n' || c == '\v'; }); + + EXPECT_EQ(s, " \f \n \r \t \vI'm a great\n\t string \n \f \n \r \t "); } TEST(Utils, trim_newline_works)