/** * @file plugin_installer_test.cpp * @brief Unit tests for the plugin installer (Catch2) * * Tests the PluginInstaller functionality including: * - Installation workflow * - Checksum verification (security-critical) * - Uninstallation * - Update checking * - Trust list management */ #include #include #include #include #include #include #include #include #include #include #include "tests/support/temp_dir_scope.hpp" namespace yams::plugins::test { namespace fs = std::filesystem; // ============================================================================= // Stub Plugin Repository Client for unit testing // ============================================================================= /** * @brief Stub implementation of IPluginRepoClient for unit testing * * This stub provides basic default behaviors without requiring GMock. * For tests that need specific behaviors, test-specific stubs can be created. */ class StubPluginRepoClient : public IPluginRepoClient { public: Result> list(const std::string& /*filter*/) override { return std::vector{}; } Result get(const std::string& /*name*/, const std::optional& /*version*/) override { return Error{ErrorCode::NotFound, "Stub: plugin not found"}; } Result> versions(const std::string& /*name*/) override { return std::vector{}; } Result exists(const std::string& /*name*/) override { return false; } }; /** * @brief Test fixture for PluginInstaller tests */ struct PluginInstallerFixture { PluginInstallerFixture() { // Create an isolated temp root per test to avoid shard races tempScope.emplace(yams::test_support::TempDirScope::unique_under("yams-installer-test")); testDir = tempScope->path(); installDir = testDir / "plugins"; trustFile = testDir / "data" / "plugins.trust"; // Create test directories fs::create_directories(installDir); fs::create_directories(trustFile.parent_path()); // Create stub repo client stubClient = std::make_shared(); } fs::path testDir; fs::path installDir; fs::path trustFile; std::optional tempScope; std::shared_ptr stubClient; }; // ============================================================================= // Basic Functionality Tests // ============================================================================= TEST_CASE_METHOD(PluginInstallerFixture, "Installer creation", "[plugins][installer]") { SECTION("makePluginInstaller creates valid installer") { auto installer = makePluginInstaller(stubClient, installDir, trustFile); REQUIRE(installer != nullptr); } } TEST_CASE_METHOD(PluginInstallerFixture, "listInstalled behavior", "[plugins][installer][list]") { SECTION("returns empty on fresh install") { auto installer = makePluginInstaller(stubClient, installDir, trustFile); auto result = installer->listInstalled(); REQUIRE(result.has_value()); CHECK(result.value().empty()); } SECTION("finds existing plugin directories") { // Create fake plugin directories fs::create_directories(installDir / "plugin-a"); fs::create_directories(installDir / "plugin-b"); auto installer = makePluginInstaller(stubClient, installDir, trustFile); auto result = installer->listInstalled(); REQUIRE(result.has_value()); CHECK(result.value().size() == 2u); auto& plugins = result.value(); CHECK(std::find(plugins.begin(), plugins.end(), "plugin-a") != plugins.end()); CHECK(std::find(plugins.begin(), plugins.end(), "plugin-b") != plugins.end()); } } TEST_CASE_METHOD(PluginInstallerFixture, "installedVersion behavior", "[plugins][installer][version]") { SECTION("returns nullopt for non-existent plugin") { auto installer = makePluginInstaller(stubClient, installDir, trustFile); auto result = installer->installedVersion("non-existent"); REQUIRE(result.has_value()); CHECK_FALSE(result.value().has_value()); } SECTION("reads version from manifest.json") { // Create plugin directory with manifest fs::create_directories(installDir / "test-plugin"); std::ofstream manifestFile(installDir / "test-plugin" / "manifest.json"); manifestFile << R"({"version": "1.2.3", "name": "test-plugin"})"; manifestFile.close(); auto installer = makePluginInstaller(stubClient, installDir, trustFile); auto result = installer->installedVersion("test-plugin"); REQUIRE(result.has_value()); REQUIRE(result.value().has_value()); CHECK(*result.value() == "1.2.3"); } SECTION("returns 'unknown' without manifest.json") { // Create plugin directory without manifest fs::create_directories(installDir / "test-plugin"); auto installer = makePluginInstaller(stubClient, installDir, trustFile); auto result = installer->installedVersion("test-plugin"); REQUIRE(result.has_value()); REQUIRE(result.value().has_value()); CHECK(*result.value() == "unknown"); } SECTION("rejects path traversal names") { auto installer = makePluginInstaller(stubClient, installDir, trustFile); auto result = installer->installedVersion("../test-plugin"); CHECK_FALSE(result.has_value()); CHECK(result.error().code == ErrorCode::InvalidArgument); } } // ============================================================================= // Uninstall Tests // ============================================================================= TEST_CASE_METHOD(PluginInstallerFixture, "uninstall behavior", "[plugins][installer][uninstall]") { SECTION("removes plugin directory") { // Create plugin directory fs::create_directories(installDir / "test-plugin"); REQUIRE(fs::exists(installDir / "test-plugin")); auto installer = makePluginInstaller(stubClient, installDir, trustFile); auto result = installer->uninstall("test-plugin"); CHECK(result.has_value()); CHECK_FALSE(fs::exists(installDir / "test-plugin")); } SECTION("fails for non-existent plugin") { auto installer = makePluginInstaller(stubClient, installDir, trustFile); auto result = installer->uninstall("non-existent"); CHECK_FALSE(result.has_value()); CHECK(result.error().code == ErrorCode::NotFound); } SECTION("rejects path traversal names without deleting outside install root") { auto outside = testDir / "outside"; fs::create_directories(outside); std::ofstream(outside / "sentinel.txt") << "keep"; REQUIRE(fs::exists(outside / "sentinel.txt")); auto installer = makePluginInstaller(stubClient, installDir, trustFile); auto result = installer->uninstall("../outside"); CHECK_FALSE(result.has_value()); CHECK(result.error().code == ErrorCode::InvalidArgument); CHECK(fs::exists(outside / "sentinel.txt")); } #if !defined(_WIN32) SECTION("trust file remains private after trust update on uninstall") { fs::create_directories(installDir / "test-plugin"); { std::ofstream trustOut(trustFile, std::ios::trunc); trustOut << "# YAMS Plugin Trust List\n"; trustOut << fs::weakly_canonical(installDir / "test-plugin").string() << "\n"; } auto installer = makePluginInstaller(stubClient, installDir, trustFile); auto result = installer->uninstall("test-plugin"); REQUIRE(result.has_value()); REQUIRE(fs::exists(trustFile)); std::error_code ec; auto perms = fs::status(trustFile, ec).permissions(); REQUIRE_FALSE(ec); CHECK((perms & (fs::perms::group_all | fs::perms::others_all)) == fs::perms::none); } #endif } // ============================================================================= // Security Tests - Checksum Verification // ============================================================================= TEST_CASE("InstallOptions defaults", "[plugins][installer][security]") { InstallOptions options; SECTION("auto-trust is on by default") { CHECK(options.autoTrust); } SECTION("auto-load is on by default") { CHECK(options.autoLoad); } SECTION("force is off by default") { CHECK_FALSE(options.force); } SECTION("dry-run is off by default") { CHECK_FALSE(options.dryRun); } SECTION("no version specified by default") { CHECK_FALSE(options.version.has_value()); } SECTION("no checksum override by default") { CHECK_FALSE(options.checksum.has_value()); } } TEST_CASE("Checksum format requirements", "[plugins][installer][security]") { // Valid SHA-256 checksum (empty file hash) const std::string emptyFileSha256 = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; SECTION("prefix is sha256:") { CHECK(emptyFileSha256.substr(0, 7) == "sha256:"); } SECTION("total length is 71 characters") { CHECK(emptyFileSha256.length() == 7 + 64); } } TEST_CASE("InstallResult contains security info", "[plugins][installer][security]") { InstallResult result; result.pluginName = "test-plugin"; result.version = "1.0.0"; result.checksum = "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd"; result.sizeBytes = 1024 * 1024; // 1MB result.wasUpgrade = false; result.elapsed = std::chrono::milliseconds{500}; CHECK_FALSE(result.checksum.empty()); CHECK(result.checksum.substr(0, 7) == "sha256:"); CHECK(result.sizeBytes > 0u); } // ============================================================================= // Progress Callback Tests // ============================================================================= TEST_CASE("InstallProgress stages", "[plugins][installer][progress]") { SECTION("all stages are defined and distinct") { auto querying = InstallProgress::Stage::Querying; auto downloading = InstallProgress::Stage::Downloading; auto verifying = InstallProgress::Stage::Verifying; auto extracting = InstallProgress::Stage::Extracting; auto complete = InstallProgress::Stage::Complete; CHECK(querying != downloading); CHECK(verifying != extracting); CHECK(complete != querying); } } TEST_CASE("InstallProgress default values", "[plugins][installer][progress]") { InstallProgress progress; CHECK(progress.stage == InstallProgress::Stage::Querying); CHECK(progress.message.empty()); CHECK(progress.progress == 0.0f); CHECK(progress.bytesDownloaded == 0u); CHECK(progress.totalBytes == 0u); } // ============================================================================= // Trust List Tests // ============================================================================= TEST_CASE_METHOD(PluginInstallerFixture, "Trust file location", "[plugins][installer][trust]") { // Create a plugin to reference trust file location fs::create_directories(installDir / "trusted-plugin"); std::ofstream manifestFile(installDir / "trusted-plugin" / "manifest.json"); manifestFile << R"({"version": "1.0.0"})"; manifestFile.close(); auto installer = makePluginInstaller(stubClient, installDir, trustFile); SECTION("trust file is in config directory") { CHECK(trustFile.parent_path().filename() == "data"); CHECK(trustFile.filename() == "plugins.trust"); } } // ============================================================================= // Update Check Tests // ============================================================================= TEST_CASE_METHOD(PluginInstallerFixture, "checkUpdates behavior", "[plugins][installer][updates]") { SECTION("returns empty with no installed plugins") { auto installer = makePluginInstaller(stubClient, installDir, trustFile); auto result = installer->checkUpdates(); REQUIRE(result.has_value()); CHECK(result.value().empty()); } } // ============================================================================= // Default Path Tests // ============================================================================= TEST_CASE("Default paths", "[plugins][installer][defaults]") { SECTION("default install dir is valid") { auto defaultDir = getDefaultPluginInstallDir(); CHECK_FALSE(defaultDir.empty()); CHECK(defaultDir.string().find("yams") != std::string::npos); CHECK(defaultDir.string().find("plugins") != std::string::npos); } SECTION("default trust file is valid") { auto defaultFile = getDefaultPluginTrustFile(); CHECK_FALSE(defaultFile.empty()); CHECK(defaultFile.string().find("yams") != std::string::npos); CHECK(defaultFile.filename().string() == "plugins.trust"); } } // ============================================================================= // Security Best Practices Verification Tests // ============================================================================= TEST_CASE("Security best practices for plugin installer", "[plugins][installer][security]") { SECTION("SHA-256 used for integrity verification") { const size_t sha256HexLength = 64; const size_t expectedTotalLength = 7 + sha256HexLength; const std::string sampleChecksum = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; CHECK(sampleChecksum.length() == expectedTotalLength); } SECTION("HTTPS is enforced for downloads") { CHECK(std::string(DEFAULT_PLUGIN_REPO_URL).substr(0, 8) == "https://"); } SECTION("TLS verification enabled by default") { PluginRepoConfig config; CHECK(config.verifyTls); } } } // namespace yams::plugins::test