#include "test_harness.h" #include "cheats/sync/cheat_sync_engine.hpp" #include "cheats/sync/cheat_sync_service.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 #include #include #include #include #include #include #include using onion::cheats::sync::CheatMirrorId; using onion::cheats::sync::CheatSyncEngine; using onion::cheats::sync::CheatSyncService; using onion::cheats::sync::CheatSyncStatus; using onion::cheats::sync::HttpRequest; using onion::cheats::sync::ICheatCatalog; using onion::cheats::sync::ICheatMirror; using onion::cheats::sync::IHttpTransport; using onion::cheats::sync::SyncCancelFn; using onion::cheats::sync::SyncStatus; namespace { using namespace std::chrono_literals; extern "C" int32_t sceKernelSendNotificationRequest(int32_t device, void *req, size_t size, int32_t blocking); std::vector g_flatten_roots; int g_flatten_result = 0; struct ProgressEvent { std::string phase; size_t completed = 0; size_t total = 0; }; std::vector g_progress_events; struct CancelState { int checks = 0; int cancel_after = 0; }; struct PhaseCancelState { const char *phase = nullptr; size_t after_completed = 0; bool requested = false; }; bool cancel_after_checks(void *user) { auto *state = static_cast(user); return state && ++state->checks >= state->cancel_after; } bool phase_cancel_requested(void *user) { const auto *state = static_cast(user); return state && state->requested; } 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; } }; class BlockingCancelHttp final : public IHttpTransport { public: SyncStatus perform( const HttpRequest &req, const std::function &) override { { std::lock_guard lock(mu_); entered_ = true; } cv_.notify_all(); if (req.on_progress) { req.on_progress(1, 10, req.progress_user); } std::unique_lock lock(mu_); while (!released_) { lock.unlock(); const bool cancelled = req.should_cancel && req.should_cancel(req.cancel_user); lock.lock(); if (cancelled) { cancel_seen_ = true; cv_.notify_all(); } cv_.wait_for(lock, 1ms, [this] { return released_; }); } lock.unlock(); return req.should_cancel && req.should_cancel(req.cancel_user) ? SyncStatus::Cancelled : SyncStatus::Network; } bool waitUntilEntered() { std::unique_lock lock(mu_); return cv_.wait_for(lock, 2s, [this] { return entered_; }); } bool waitUntilCancelSeen() { std::unique_lock lock(mu_); return cv_.wait_for(lock, 2s, [this] { return cancel_seen_; }); } void release() { { std::lock_guard lock(mu_); released_ = true; } cv_.notify_all(); } void reset() { std::lock_guard lock(mu_); entered_ = false; cancel_seen_ = false; released_ = false; } private: std::mutex mu_; std::condition_variable cv_; bool entered_ = false; bool cancel_seen_ = false; bool released_ = false; }; struct CapturedNotification { std::string message; bool debug = false; }; std::mutex g_notification_mu; std::vector g_notifications; int32_t capture_notification(int32_t, void *request, size_t size, int32_t) { constexpr size_t kIconFlagOffset = 0x2c; constexpr size_t kMessageOffset = 0x2d; if (!request || size <= kMessageOffset) { return 0; } const auto *bytes = static_cast(request); const char *message = reinterpret_cast(bytes + kMessageOffset); std::lock_guard lock(g_notification_mu); g_notifications.push_back( CapturedNotification{message, bytes[kIconFlagOffset] == 0}); return 0; } size_t notification_count(const char *message, bool debug) { std::lock_guard lock(g_notification_mu); return static_cast(std::count_if( g_notifications.begin(), g_notifications.end(), [message, debug](const CapturedNotification ¬ification) { return notification.debug == debug && notification.message == message; })); } bool wait_until_notification(const char *message, bool debug, size_t expected_count) { const auto deadline = std::chrono::steady_clock::now() + 2s; while (std::chrono::steady_clock::now() < deadline) { if (notification_count(message, debug) >= expected_count) { return true; } std::this_thread::sleep_for(1ms); } return false; } bool wait_until_not_running(CheatSyncService &service) { const auto deadline = std::chrono::steady_clock::now() + 2s; while (std::chrono::steady_clock::now() < deadline) { if (service.status().state != CheatSyncStatus::State::Running) { return true; } std::this_thread::sleep_for(1ms); } return false; } class ServiceTestScope { public: ServiceTestScope(CheatSyncService &service, BlockingCancelHttp &http) : service_(service), http_(http) { { std::lock_guard lock(g_notification_mu); g_notifications.clear(); } onion_notify_set_language(ONION_NOTIFY_LANG_ZH_HANS); onion_notify_set_send(capture_notification); service_.setHttpTransportForTest(&http_); } ~ServiceTestScope() { const CheatSyncStatus current = service_.status(); if (current.state == CheatSyncStatus::State::Running) { (void)service_.cancel(current.task_id); } http_.release(); (void)wait_until_not_running(service_); service_.setHttpTransportForTest(nullptr); onion_notify_set_send(sceKernelSendNotificationRequest); onion_notify_set_language(ONION_NOTIFY_LANG_EN); } private: CheatSyncService &service_; BlockingCancelHttp &http_; }; 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/cheats/game2.shn", 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; } SyncStatus capture_flatten(const char *root, CheatSyncEngine::InstallProgressFn progress, void *progress_user, SyncCancelFn should_cancel, void *cancel_user) { g_flatten_roots.emplace_back(root ? root : ""); if (g_flatten_result != 0 || !root) { return SyncStatus::Io; } std::ifstream input(std::filesystem::path(root) / "game.json"); std::string contents; std::getline(input, contents); if (contents != "{\"name\":\"fixture\"}") { return SyncStatus::Io; } if (progress) { progress(0, 3, progress_user); for (size_t completed = 1; completed <= 3; ++completed) { progress(completed, 3, progress_user); if (should_cancel && should_cancel(cancel_user)) { return SyncStatus::Cancelled; } } } return SyncStatus::Ok; } void capture_progress(const char *phase, size_t completed, size_t total, void *) { g_progress_events.push_back( ProgressEvent{phase ? phase : "", completed, total}); } void capture_progress_and_cancel(const char *phase, size_t completed, size_t total, void *user) { capture_progress(phase, completed, total, nullptr); auto *state = static_cast(user); if (state && phase && state->phase && phase == std::string(state->phase) && completed >= state->after_completed) { state->requested = true; } } 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; g_progress_events.clear(); } 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); engine.setProgressHandler(capture_progress, nullptr); 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()); const auto install_begin = std::find_if( g_progress_events.begin(), g_progress_events.end(), [](const ProgressEvent &event) { return event.phase == "install" && event.completed == 0; }); const auto install_end = std::find_if( g_progress_events.begin(), g_progress_events.end(), [](const ProgressEvent &event) { return event.phase == "install" && event.completed == 3 && event.total == 3; }); const auto cleanup = std::find_if( g_progress_events.begin(), g_progress_events.end(), [](const ProgressEvent &event) { return event.phase == "cleanup"; }); TEST_ASSERT_TRUE(install_begin != g_progress_events.end()); TEST_ASSERT_TRUE(install_end != g_progress_events.end()); TEST_ASSERT_TRUE(cleanup != g_progress_events.end()); TEST_ASSERT_TRUE(install_begin < install_end); TEST_ASSERT_TRUE(install_end < cleanup); 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; } static int test_cancel_during_download_cleans_temp(void) { reset_test_root(); FakeCatalog catalog; FakeMirror mirror(CheatMirrorId::Github, "github", "codeload.github.com", "https://codeload.github.com/org/fake/archive.zip"); MockHttp http; http.archive = make_archive(); CancelState cancel{0, 2}; CheatSyncEngine engine(http, capture_flatten); engine.setCancelHandler(cancel_after_checks, &cancel); const auto result = engine.run(catalog, mirror, nullptr, test_root().c_str()); TEST_ASSERT_TRUE(result.status == SyncStatus::Cancelled); TEST_ASSERT_TRUE(g_flatten_roots.empty()); TEST_ASSERT_TRUE(temp_was_removed()); return 0; } static int test_cancel_during_extract_cleans_temp(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(); PhaseCancelState cancel{"extract", 1, false}; CheatSyncEngine engine(http, capture_flatten); engine.setProgressHandler(capture_progress_and_cancel, &cancel); engine.setCancelHandler(phase_cancel_requested, &cancel); const auto result = engine.run(catalog, mirror, nullptr, test_root().c_str()); TEST_ASSERT_TRUE(result.status == SyncStatus::Cancelled); TEST_ASSERT_TRUE(g_flatten_roots.empty()); TEST_ASSERT_TRUE(temp_was_removed()); return 0; } static int test_cancel_during_install_cleans_temp(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(); PhaseCancelState cancel{"install", 1, false}; CheatSyncEngine engine(http, capture_flatten); engine.setProgressHandler(capture_progress_and_cancel, &cancel); engine.setCancelHandler(phase_cancel_requested, &cancel); const auto result = engine.run(catalog, mirror, nullptr, test_root().c_str()); TEST_ASSERT_TRUE(result.status == SyncStatus::Cancelled); TEST_ASSERT_EQ_INT(1, static_cast(g_flatten_roots.size())); TEST_ASSERT_TRUE(temp_was_removed()); return 0; } static int test_service_cancel_lifecycle(void) { const std::filesystem::path service_temp = std::filesystem::path(ONION_DATA_ROOT) / "cheats_tmp"; std::error_code error; std::filesystem::remove_all(service_temp, error); CheatSyncService &service = CheatSyncService::instance(); BlockingCancelHttp http; ServiceTestScope scope(service, http); onion::Settings settings; settings.ui_lang = onion::kUiLanguageZhHans; const char *const cancelling_key = "notify.cheats.sync.cancelling"; const std::string cancelling_text = onion_notify_tr(cancelling_key); TEST_ASSERT_TRUE(cancelling_text != cancelling_key); const char *const cancelled_key = "notify.cheats.sync.cancelled"; const std::string cancelled_text = onion_notify_tr(cancelled_key); TEST_ASSERT_TRUE(cancelled_text != cancelled_key); uint32_t first_task = 0; TEST_ASSERT_TRUE(service.start(settings, "hen-cheats-collection", "github", &first_task) == CheatSyncService::StartResult::Started); TEST_ASSERT_TRUE(first_task != 0); TEST_ASSERT_TRUE(http.waitUntilEntered()); uint32_t busy_task = 0; TEST_ASSERT_TRUE(service.start(settings, "hen-cheats-collection", "github", &busy_task) == CheatSyncService::StartResult::AlreadyRunning); TEST_ASSERT_EQ_U64(first_task, busy_task); TEST_ASSERT_TRUE(!service.cancel(0)); TEST_ASSERT_TRUE(!service.cancel(first_task + 1)); TEST_ASSERT_EQ_U64(0, notification_count(cancelling_text.c_str(), true)); TEST_ASSERT_EQ_U64(0, notification_count(cancelled_text.c_str(), true)); TEST_ASSERT_TRUE(service.cancel(first_task)); const CheatSyncStatus cancelling = service.status(); TEST_ASSERT_TRUE(cancelling.state == CheatSyncStatus::State::Running); TEST_ASSERT_STREQ("cancel", cancelling.phase.c_str()); TEST_ASSERT_EQ_INT(-1, cancelling.progress_percent); TEST_ASSERT_TRUE(service.cancellationRequested()); TEST_ASSERT_EQ_U64(1, notification_count(cancelling_text.c_str(), true)); TEST_ASSERT_TRUE(!service.cancel(first_task)); TEST_ASSERT_EQ_U64(1, notification_count(cancelling_text.c_str(), true)); service.noteProgress("cleanup", 100, 99, 99); const CheatSyncStatus after_late_progress = service.status(); TEST_ASSERT_STREQ("cancel", after_late_progress.phase.c_str()); TEST_ASSERT_EQ_INT(-1, after_late_progress.progress_percent); TEST_ASSERT_EQ_U64(cancelling.completed, after_late_progress.completed); TEST_ASSERT_EQ_U64(cancelling.total, after_late_progress.total); TEST_ASSERT_TRUE(http.waitUntilCancelSeen()); TEST_ASSERT_TRUE(std::filesystem::exists(service_temp)); TEST_ASSERT_EQ_U64(0, notification_count(cancelled_text.c_str(), true)); http.release(); TEST_ASSERT_TRUE(wait_until_not_running(service)); TEST_ASSERT_TRUE(wait_until_notification(cancelled_text.c_str(), true, 1)); const CheatSyncStatus cancelled = service.status(); TEST_ASSERT_TRUE(cancelled.state == CheatSyncStatus::State::Idle); TEST_ASSERT_EQ_U64(first_task, cancelled.task_id); TEST_ASSERT_TRUE(cancelled.error.empty()); TEST_ASSERT_TRUE(!service.cancellationRequested()); TEST_ASSERT_TRUE(!std::filesystem::exists(service_temp)); TEST_ASSERT_EQ_U64(1, notification_count(cancelling_text.c_str(), true)); TEST_ASSERT_EQ_U64(1, notification_count(cancelled_text.c_str(), true)); http.reset(); uint32_t second_task = 0; TEST_ASSERT_TRUE(service.start(settings, "hen-cheats-collection", "github", &second_task) == CheatSyncService::StartResult::Started); TEST_ASSERT_TRUE(second_task != 0 && second_task != first_task); TEST_ASSERT_TRUE(http.waitUntilEntered()); TEST_ASSERT_TRUE(!service.cancel(first_task)); TEST_ASSERT_TRUE(service.cancel(second_task)); TEST_ASSERT_TRUE(http.waitUntilCancelSeen()); http.release(); TEST_ASSERT_TRUE(wait_until_not_running(service)); TEST_ASSERT_TRUE(wait_until_notification(cancelled_text.c_str(), true, 2)); TEST_ASSERT_TRUE(service.status().state == CheatSyncStatus::State::Idle); TEST_ASSERT_TRUE(!std::filesystem::exists(service_temp)); TEST_ASSERT_EQ_U64(2, notification_count(cancelling_text.c_str(), true)); TEST_ASSERT_EQ_U64(2, notification_count(cancelled_text.c_str(), true)); 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); fails += onion_test_run("sync.cancel_download", test_cancel_during_download_cleans_temp); fails += onion_test_run("sync.cancel_extract", test_cancel_during_extract_cleans_temp); fails += onion_test_run("sync.cancel_install", test_cancel_during_install_cleans_temp); fails += onion_test_run("sync.service_cancel_lifecycle", test_service_cancel_lifecycle); return fails; }