#include "test_harness.h" #include "cheats/sync/cheat_sync_engine.hpp" #include "cheats/sync/i_cheat_catalog.hpp" #include "cheats/sync/i_cheat_mirror.hpp" #include "cheats/sync/i_http_transport.hpp" #include #include #include #include #include #include using onion::cheats::sync::CheatMirrorId; using onion::cheats::sync::CheatSyncEngine; using onion::cheats::sync::HttpRequest; using onion::cheats::sync::ICheatCatalog; using onion::cheats::sync::ICheatMirror; using onion::cheats::sync::IHttpTransport; using onion::cheats::sync::SyncStatus; namespace { std::vector g_flatten_roots; int g_flatten_result = 0; const std::filesystem::path &test_root() { static const std::filesystem::path root = std::filesystem::path(ONION_DATA_ROOT) / "sync-engine"; return root; } class FakeCatalog final : public ICheatCatalog { public: const char *id() const override { return "fake-collection"; } const char *defaultBranch() const override { return "main"; } const char *slugFor(CheatMirrorId) const override { return "org/fake"; } const char *const *flattenRoots(size_t *count) const override { static const char *const kRoots[] = {"cheats"}; if (count) { *count = 1; } return kRoots; } }; class FakeMirror final : public ICheatMirror { public: FakeMirror(CheatMirrorId id, const char *name, const char *host, const char *url) : id_(id), name_(name), host_(host), url_(url) {} CheatMirrorId id() const override { return id_; } const char *name() const override { return name_; } const char *archiveHost() const override { return host_; } std::string archiveUrl(const ICheatCatalog &) const override { return url_; } private: CheatMirrorId id_; const char *name_; const char *host_; const char *url_; }; class MockHttp final : public IHttpTransport { public: std::vector responses{SyncStatus::Ok}; std::vector archive; std::vector urls; std::vector hosts; size_t max_body_bytes = 0; SyncStatus perform( const HttpRequest &req, const std::function &on_data) override { urls.emplace_back(req.url ? req.url : ""); hosts.emplace_back(req.host_allow ? req.host_allow : ""); max_body_bytes = req.max_body_bytes; const size_t index = urls.size() - 1; const SyncStatus response = index < responses.size() ? responses[index] : responses.back(); if (response != SyncStatus::Ok) { return response; } if (req.on_progress) { req.on_progress(archive.size(), archive.size(), req.progress_user); } return on_data && !archive.empty() ? on_data(archive.data(), archive.size()) : SyncStatus::Ok; } }; std::vector make_archive() { mz_zip_archive zip{}; void *data = nullptr; size_t size = 0; const char cheat[] = "{\"name\":\"fixture\"}"; const char ignored[] = "ignored"; if (!mz_zip_writer_init_heap(&zip, 0, 0) || !mz_zip_writer_add_mem(&zip, "repo-main/cheats/game.json", cheat, sizeof(cheat) - 1, MZ_BEST_SPEED) || !mz_zip_writer_add_mem(&zip, "repo-main/website/index.html", ignored, sizeof(ignored) - 1, MZ_BEST_SPEED) || !mz_zip_writer_finalize_heap_archive(&zip, &data, &size)) { mz_zip_writer_end(&zip); return {}; } std::vector out(static_cast(data), static_cast(data) + size); mz_free(data); mz_zip_writer_end(&zip); return out; } int capture_flatten(const char *root) { g_flatten_roots.emplace_back(root ? root : ""); if (g_flatten_result != 0 || !root) { return g_flatten_result; } std::ifstream input(std::filesystem::path(root) / "game.json"); std::string contents; std::getline(input, contents); return contents == "{\"name\":\"fixture\"}" ? 0 : -1; } void reset_test_root() { std::error_code error; std::filesystem::remove_all(test_root(), error); std::filesystem::create_directories(test_root(), error); g_flatten_roots.clear(); g_flatten_result = 0; } bool temp_was_removed() { return !std::filesystem::exists(test_root() / "cheats_tmp"); } } // namespace static int test_download_extract_install_cleanup(void) { reset_test_root(); FakeCatalog catalog; FakeMirror mirror(CheatMirrorId::Github, "github", "codeload.github.com", "https://codeload.github.com/org/fake/zip/refs/heads/main"); MockHttp http; http.archive = make_archive(); TEST_ASSERT_TRUE(!http.archive.empty()); CheatSyncEngine engine(http, capture_flatten); const auto result = engine.run(catalog, mirror, nullptr, test_root().c_str()); TEST_ASSERT_TRUE(result.status == SyncStatus::Ok); TEST_ASSERT_TRUE(result.used_mirror == CheatMirrorId::Github); TEST_ASSERT_EQ_INT(1, static_cast(http.urls.size())); TEST_ASSERT_STREQ("codeload.github.com", http.hosts[0].c_str()); TEST_ASSERT_TRUE(http.max_body_bytes == 64ull * 1024ull * 1024ull); TEST_ASSERT_EQ_INT(1, static_cast(g_flatten_roots.size())); TEST_ASSERT_TRUE(temp_was_removed()); return 0; } static int test_network_failure_uses_fallback(void) { reset_test_root(); FakeCatalog catalog; FakeMirror primary(CheatMirrorId::Cnb, "cnb", "cnb.cool", "https://cnb.cool/org/fake/archive.zip"); FakeMirror fallback(CheatMirrorId::Github, "github", "codeload.github.com", "https://codeload.github.com/org/fake/archive.zip"); MockHttp http; http.responses = {SyncStatus::Network, SyncStatus::Ok}; http.archive = make_archive(); CheatSyncEngine engine(http, capture_flatten); const auto result = engine.run(catalog, primary, &fallback, test_root().c_str()); TEST_ASSERT_TRUE(result.status == SyncStatus::Ok); TEST_ASSERT_TRUE(result.used_mirror == CheatMirrorId::Github); TEST_ASSERT_EQ_INT(2, static_cast(http.urls.size())); TEST_ASSERT_STREQ("cnb.cool", http.hosts[0].c_str()); TEST_ASSERT_STREQ("codeload.github.com", http.hosts[1].c_str()); TEST_ASSERT_TRUE(temp_was_removed()); return 0; } static int test_explicit_source_failure_does_not_fallback(void) { reset_test_root(); FakeCatalog catalog; FakeMirror mirror(CheatMirrorId::Cnb, "cnb", "cnb.cool", "https://cnb.cool/org/fake/archive.zip"); MockHttp http; http.responses = {SyncStatus::Network}; CheatSyncEngine engine(http, capture_flatten); const auto result = engine.run(catalog, mirror, nullptr, test_root().c_str()); TEST_ASSERT_TRUE(result.status == SyncStatus::Network); TEST_ASSERT_EQ_INT(1, static_cast(http.urls.size())); TEST_ASSERT_TRUE(g_flatten_roots.empty()); TEST_ASSERT_TRUE(temp_was_removed()); return 0; } static int test_install_failure_does_not_switch_source(void) { reset_test_root(); FakeCatalog catalog; FakeMirror primary(CheatMirrorId::Github, "github", "codeload.github.com", "https://codeload.github.com/org/fake/archive.zip"); FakeMirror fallback(CheatMirrorId::Cnb, "cnb", "cnb.cool", "https://cnb.cool/org/fake/archive.zip"); MockHttp http; http.archive = make_archive(); g_flatten_result = -1; CheatSyncEngine engine(http, capture_flatten); const auto result = engine.run(catalog, primary, &fallback, test_root().c_str()); TEST_ASSERT_TRUE(result.status == SyncStatus::Io); TEST_ASSERT_EQ_INT(1, static_cast(http.urls.size())); TEST_ASSERT_TRUE(result.used_mirror == CheatMirrorId::Github); TEST_ASSERT_TRUE(temp_was_removed()); return 0; } static int test_non_https_archive_is_rejected(void) { reset_test_root(); FakeCatalog catalog; FakeMirror mirror(CheatMirrorId::Github, "github", "example.test", "http://example.test/archive.zip"); MockHttp http; CheatSyncEngine engine(http, capture_flatten); const auto result = engine.run(catalog, mirror, nullptr, test_root().c_str()); TEST_ASSERT_TRUE(result.status == SyncStatus::Rejected); TEST_ASSERT_TRUE(http.urls.empty()); return 0; } static int test_tls_failure_uses_fallback(void) { reset_test_root(); FakeCatalog catalog; FakeMirror primary(CheatMirrorId::Cnb, "cnb", "cnb.cool", "https://cnb.cool/org/fake/archive.zip"); FakeMirror fallback(CheatMirrorId::Github, "github", "codeload.github.com", "https://codeload.github.com/org/fake/archive.zip"); MockHttp http; http.responses = {SyncStatus::Tls, SyncStatus::Ok}; http.archive = make_archive(); CheatSyncEngine engine(http, capture_flatten); const auto result = engine.run(catalog, primary, &fallback, test_root().c_str()); TEST_ASSERT_TRUE(result.status == SyncStatus::Ok); TEST_ASSERT_TRUE(result.used_mirror == CheatMirrorId::Github); TEST_ASSERT_EQ_INT(2, static_cast(http.urls.size())); TEST_ASSERT_TRUE(temp_was_removed()); return 0; } static int test_clock_failure_sets_specific_error(void) { reset_test_root(); FakeCatalog catalog; FakeMirror mirror(CheatMirrorId::Cnb, "cnb", "cnb.cool", "https://cnb.cool/org/fake/archive.zip"); MockHttp http; http.responses = {SyncStatus::Clock}; CheatSyncEngine engine(http, capture_flatten); const auto result = engine.run(catalog, mirror, nullptr, test_root().c_str()); TEST_ASSERT_TRUE(result.status == SyncStatus::Clock); TEST_ASSERT_STREQ("system_clock", result.error.c_str()); TEST_ASSERT_EQ_INT(1, static_cast(http.urls.size())); TEST_ASSERT_TRUE(temp_was_removed()); return 0; } extern "C" int test_cheat_sync_suite(void) { int fails = 0; fails += onion_test_run("sync.archive_install", test_download_extract_install_cleanup); fails += onion_test_run("sync.fallback", test_network_failure_uses_fallback); fails += onion_test_run("sync.no_fallback", test_explicit_source_failure_does_not_fallback); fails += onion_test_run("sync.install_no_switch", test_install_failure_does_not_switch_source); fails += onion_test_run("sync.reject_http", test_non_https_archive_is_rejected); fails += onion_test_run("sync.tls_fallback", test_tls_failure_uses_fallback); fails += onion_test_run("sync.clock_error", test_clock_failure_sets_specific_error); return fails; }