#include "nocal/storage/sync_cache.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #if !defined(_WIN32) #include #include #endif namespace { using nocal::storage::CacheSnapshot; using nocal::storage::CachedAccount; using nocal::storage::CachedCalendar; using nocal::storage::CachedEvent; using nocal::storage::CachedEventInstance; using nocal::storage::PullPage; using nocal::storage::SyncCache; using nocal::storage::SyncCheckpoint; void require(bool condition, std::string_view message) { if (!condition) { throw std::runtime_error(std::string{message}); } } template void require_throws(Function&& function, std::string_view message) { try { function(); } catch (const std::exception&) { return; } throw std::runtime_error(std::string{message}); } class TempDirectory { public: TempDirectory() { #if !defined(_WIN32) std::string pattern = (std::filesystem::temp_directory_path() / "nocal-sync-cache-tests.XXXXXX").string(); std::vector writable(pattern.begin(), pattern.end()); writable.push_back('\0'); const char* created = ::mkdtemp(writable.data()); if (created == nullptr) { throw std::runtime_error("mkdtemp failed"); } path_ = created; #else path_ = std::filesystem::temp_directory_path() / "nocal-sync-cache-tests"; std::filesystem::remove_all(path_); std::filesystem::create_directories(path_); #endif } TempDirectory(const TempDirectory&) = delete; TempDirectory& operator=(const TempDirectory&) = delete; ~TempDirectory() { std::error_code ignored; std::filesystem::remove_all(path_, ignored); } [[nodiscard]] const std::filesystem::path& path() const noexcept { return path_; } private: std::filesystem::path path_; }; class SqliteConnection { public: explicit SqliteConnection(const std::filesystem::path& path) { const int result = sqlite3_open_v2( path.c_str(), &database_, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nullptr); if (result != SQLITE_OK) { const std::string message = database_ == nullptr ? "sqlite open failed" : sqlite3_errmsg(database_); if (database_ != nullptr) { sqlite3_close(database_); database_ = nullptr; } throw std::runtime_error(message); } } SqliteConnection(const SqliteConnection&) = delete; SqliteConnection& operator=(const SqliteConnection&) = delete; ~SqliteConnection() { if (database_ != nullptr) { sqlite3_close(database_); } } void execute(std::string_view sql) { char* error = nullptr; const std::string statement{sql}; const int result = sqlite3_exec(database_, statement.c_str(), nullptr, nullptr, &error); if (result != SQLITE_OK) { const std::string message = error == nullptr ? sqlite3_errmsg(database_) : error; sqlite3_free(error); throw std::runtime_error(message); } } [[nodiscard]] std::string scalar_text(std::string_view sql) const { sqlite3_stmt* statement = nullptr; const std::string query{sql}; if (sqlite3_prepare_v2(database_, query.c_str(), -1, &statement, nullptr) != SQLITE_OK) { throw std::runtime_error(sqlite3_errmsg(database_)); } struct Finalize { sqlite3_stmt* statement; ~Finalize() { sqlite3_finalize(statement); } } finalize{statement}; if (sqlite3_step(statement) != SQLITE_ROW) { throw std::runtime_error(sqlite3_errmsg(database_)); } const unsigned char* value = sqlite3_column_text(statement, 0); return value == nullptr ? std::string{} : reinterpret_cast(value); } [[nodiscard]] int scalar_int(std::string_view sql) const { sqlite3_stmt* statement = nullptr; const std::string query{sql}; if (sqlite3_prepare_v2(database_, query.c_str(), -1, &statement, nullptr) != SQLITE_OK) { throw std::runtime_error(sqlite3_errmsg(database_)); } struct Finalize { sqlite3_stmt* statement; ~Finalize() { sqlite3_finalize(statement); } } finalize{statement}; if (sqlite3_step(statement) != SQLITE_ROW) { throw std::runtime_error(sqlite3_errmsg(database_)); } return sqlite3_column_int(statement, 0); } [[nodiscard]] std::vector first_column(std::string_view sql) const { sqlite3_stmt* statement = nullptr; const std::string query{sql}; if (sqlite3_prepare_v2(database_, query.c_str(), -1, &statement, nullptr) != SQLITE_OK) { throw std::runtime_error(sqlite3_errmsg(database_)); } struct Finalize { sqlite3_stmt* statement; ~Finalize() { sqlite3_finalize(statement); } } finalize{statement}; std::vector values; int result = SQLITE_ROW; while ((result = sqlite3_step(statement)) == SQLITE_ROW) { const unsigned char* value = sqlite3_column_text(statement, 0); values.emplace_back(value == nullptr ? "" : reinterpret_cast(value)); } if (result != SQLITE_DONE) { throw std::runtime_error(sqlite3_errmsg(database_)); } return values; } private: sqlite3* database_{nullptr}; }; CachedAccount account(std::string id = "account-a") { return CachedAccount{std::move(id), "microsoft-graph", "subject-a", "Work account"}; } CachedCalendar calendar( std::string id = "calendar-a", std::string account_id = "account-a") { return CachedCalendar{std::move(id), std::move(account_id), "remote-calendar-a", "Calendar A", "blue", false, true, R"({"calendar":"a"})"}; } CachedEvent event(std::string id = "event-a", std::string calendar_id = "calendar-a") { return CachedEvent{std::move(id), std::move(calendar_id), "remote-event-a", "uid-a", "etag-a", "change-a", R"({"event":"a"})", false}; } CachedEventInstance instance( std::string id = "instance-a", std::string event_id = "event-a") { return CachedEventInstance{std::move(id), std::move(event_id), 1'000'000, 2'000'000, false, "Event A", "Room", "Description", "Europe/London"}; } SyncCheckpoint checkpoint(std::string cursor = "cursor-a", std::string calendar_id = "calendar-a", std::string window_start = "opaque-start", std::string window_end = "opaque-end") { return SyncCheckpoint{std::move(calendar_id), std::move(window_start), std::move(window_end), std::move(cursor), false}; } PullPage page(CachedEvent changed_event = event(), CachedEventInstance changed_instance = instance(), SyncCheckpoint next = checkpoint()) { return PullPage{{std::move(changed_event)}, {std::move(changed_instance)}, std::move(next)}; } void seed_calendar(SyncCache& cache, const CachedAccount& cached_account = account(), const CachedCalendar& cached_calendar = calendar()) { cache.upsert_account(cached_account); const std::vector calendars{cached_calendar}; cache.replace_calendars_after_complete_listing(cached_account.id, calendars); } [[nodiscard]] bool contains(const std::vector& values, std::string_view value) { return std::ranges::find(values, value) != values.end(); } #if !defined(_WIN32) void require_private_file(const std::filesystem::path& path, std::string_view description) { struct stat metadata {}; require(::stat(path.c_str(), &metadata) == 0, std::string{"could not stat "}.append(description)); require(S_ISREG(metadata.st_mode), std::string{description}.append(" is not a regular file")); require((metadata.st_mode & (S_IRWXG | S_IRWXO)) == 0, std::string{description}.append(" grants group or other permissions")); require((metadata.st_mode & S_IRUSR) != 0 && (metadata.st_mode & S_IWUSR) != 0, std::string{description}.append(" is not owner-readable and writable")); } #endif void test_fresh_schema_permissions_and_secrets(const std::filesystem::path& root) { const auto path = root / "fresh.db"; SyncCache cache{path}; require(cache.path() == path, "cache did not retain its path"); require(std::filesystem::is_regular_file(path), "fresh cache database was not created"); SqliteConnection inspection{path}; require(inspection.scalar_text("PRAGMA journal_mode") == "wal", "cache is not in WAL mode"); require(inspection.scalar_int("PRAGMA user_version") == SyncCache::current_schema_version, "fresh cache schema version is incorrect"); const auto tables = inspection.first_column( "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"); for (const std::string_view required : {"accounts", "calendars", "events", "event_instances", "sync_state", "outbox", "conflicts"}) { require(contains(tables, required), std::string{"missing cache table: "}.append(required)); } for (const std::string_view child : {"calendars", "events", "event_instances", "sync_state"}) { require(inspection.scalar_int( std::string{"SELECT COUNT(*) FROM pragma_foreign_key_list('"} .append(child) .append("')")) > 0, std::string{"missing foreign key on "}.append(child)); } const auto columns = inspection.first_column( "SELECT lower(m.name || '.' || p.name) FROM sqlite_master AS m " "JOIN pragma_table_info(m.name) AS p WHERE m.type='table'"); for (const auto& column : columns) { require(column.find("token") == std::string::npos && column.find("secret") == std::string::npos && column.find("credential") == std::string::npos, "cache schema contains a token, secret, or credential column"); } #if !defined(_WIN32) require_private_file(path, "cache database"); for (const auto suffix : {"-wal", "-shm"}) { const std::filesystem::path sidecar = path.string() + suffix; require(std::filesystem::exists(sidecar), "WAL sidecar was not created"); require_private_file(sidecar, "cache sidecar"); } #endif } void test_existing_permissions_are_tightened(const std::filesystem::path& root) { #if !defined(_WIN32) const auto path = root / "permissive.db"; { SqliteConnection database{path}; database.execute("PRAGMA user_version=0"); } require(::chmod(path.c_str(), 0666) == 0, "could not make fixture permissive"); SyncCache cache{path}; require_private_file(path, "existing cache database"); #else static_cast(root); #endif } void test_account_calendars_and_deterministic_snapshot(const std::filesystem::path& root) { SyncCache cache{root / "listing.db"}; auto account_b = account("account-b"); account_b.remote_subject = "subject-b"; auto account_a = account(); cache.upsert_account(account_b); cache.upsert_account(account_a); auto calendar_b = calendar("calendar-b"); calendar_b.remote_id = "remote-calendar-b"; calendar_b.name = "Calendar B"; auto calendar_a = calendar(); cache.replace_calendars_after_complete_listing( account_a.id, std::vector{calendar_b, calendar_a}); auto first = cache.snapshot(); require(first.accounts.size() == 2 && first.accounts[0].id == "account-a" && first.accounts[1].id == "account-b", "accounts are not returned deterministically by ID"); require(first.calendars.size() == 2 && first.calendars[0].id == "calendar-a" && first.calendars[1].id == "calendar-b", "calendars are not returned deterministically by ID"); calendar_a.name = "Renamed"; cache.replace_calendars_after_complete_listing( account_a.id, std::vector{calendar_a}); const auto second = cache.snapshot(); require(second.calendars.size() == 2, "omitted calendar was deleted"); require(second.calendars[0].active && second.calendars[0].name == "Renamed", "listed calendar was not updated and kept active"); require(!second.calendars[1].active, "omitted calendar was not deactivated"); require(second == cache.snapshot(), "unchanged snapshots are not deterministic"); } void test_pull_pages_replacement_tombstone_and_windows(const std::filesystem::path& root) { SyncCache cache{root / "pull.db"}; seed_calendar(cache); auto event_b = event("event-b"); event_b.remote_id = "remote-event-b"; event_b.uid = "uid-b"; event_b.raw_payload = R"({"event":"b"})"; auto instance_b = instance("instance-b", "event-b"); instance_b.title = "Event B"; PullPage first{{event_b, event()}, {instance_b, instance()}, checkpoint("cursor-1")}; cache.apply_pull_page(first); const auto initial = cache.snapshot(); require(initial.events.size() == 2 && initial.instances.size() == 2, "pull page did not atomically add events and instances"); require(initial.events[0].id == "event-a" && initial.events[1].id == "event-b" && initial.instances[0].id == "instance-a" && initial.instances[1].id == "instance-b", "events and instances are not returned deterministically by ID"); require(initial.checkpoints.size() == 1 && initial.checkpoints[0].cursor == "cursor-1", "pull page did not advance its cursor"); cache.apply_pull_page(first); require(cache.snapshot() == initial, "repeated pull page was not idempotent"); auto changed = event(); changed.etag = "etag-a-2"; changed.raw_payload = R"({"event":"a2"})"; auto replacement = instance("instance-a-2"); replacement.title = "Changed A"; cache.apply_pull_page(page(changed, replacement, checkpoint("cursor-2"))); auto replaced = cache.snapshot(); require(replaced.events.size() == 2 && replaced.instances.size() == 2, "changed event replacement affected an untouched event"); require(contains(std::vector{replaced.instances[0].id, replaced.instances[1].id}, "instance-a-2") && contains(std::vector{ replaced.instances[0].id, replaced.instances[1].id}, "instance-b"), "changed event instances were not replaced independently"); auto tombstone = event(); tombstone.deleted = true; tombstone.raw_payload.clear(); cache.apply_pull_page(PullPage{{tombstone}, {}, checkpoint("cursor-3")}); const auto deleted = cache.snapshot(); const auto deleted_event = std::ranges::find_if( deleted.events, [](const CachedEvent& value) { return value.id == "event-a"; }); require(deleted_event != deleted.events.end() && deleted_event->deleted, "tombstone did not retain a deleted event record"); require(deleted_event->raw_payload == changed.raw_payload, "empty tombstone did not retain the prior raw payload"); require(std::ranges::none_of(deleted.instances, [](const CachedEventInstance& value) { return value.event_id == "event-a"; }), "tombstone retained event instances"); cache.apply_pull_page(PullPage{{}, {}, checkpoint("other-cursor", "calendar-a", "second-window-start", "second-window-end")}); const auto windows = cache.snapshot(); require(windows.checkpoints.size() == 2, "independent checkpoint windows for one calendar were overwritten"); require(windows.checkpoints[0].window_start == "opaque-start" && windows.checkpoints[1].window_start == "second-window-start", "checkpoints are not returned in deterministic scope order"); } void test_multiple_connections_serialize(const std::filesystem::path& root) { const auto path = root / "connections.db"; SyncCache first{path}; SyncCache second{path}; std::exception_ptr first_error; std::exception_ptr second_error; std::atomic begin{false}; auto write = [&begin](SyncCache& cache, CachedAccount value, std::exception_ptr& error) { while (!begin.load(std::memory_order_acquire)) { std::this_thread::yield(); } try { cache.upsert_account(value); } catch (...) { error = std::current_exception(); } }; auto account_b = account("account-b"); account_b.remote_subject = "subject-b"; std::thread one{write, std::ref(first), account(), std::ref(first_error)}; std::thread two{write, std::ref(second), account_b, std::ref(second_error)}; begin.store(true, std::memory_order_release); one.join(); two.join(); require(first_error == nullptr && second_error == nullptr, "ordinary concurrent writes leaked SQLITE_BUSY or another error"); const auto snapshot = first.snapshot(); require(snapshot.accounts.size() == 2, "serialized connections lost a write"); } void test_input_validation_and_rollback(const std::filesystem::path& root) { SyncCache cache{root / "validation.db"}; seed_calendar(cache); auto account_b = account("account-b"); account_b.remote_subject = "subject-b"; cache.upsert_account(account_b); auto calendar_b = calendar("calendar-b", "account-b"); calendar_b.remote_id = "remote-calendar-b"; calendar_b.raw_payload = R"({"calendar":"b"})"; cache.replace_calendars_after_complete_listing("account-b", std::vector{calendar_b}); cache.apply_pull_page(page()); const CacheSnapshot baseline = cache.snapshot(); auto expect_unchanged = [&](auto&& operation, std::string_view message) { require_throws(std::forward(operation), message); require(cache.snapshot() == baseline, "failed operation changed the cache snapshot"); }; auto empty_account = account(); empty_account.id.clear(); expect_unchanged([&] { cache.upsert_account(empty_account); }, "empty account ID accepted"); auto reassigned = account(); reassigned.remote_subject = "different-subject"; expect_unchanged([&] { cache.upsert_account(reassigned); }, "account local ID identity reassignment accepted"); auto provider_reassigned = account(); provider_reassigned.provider = "different-provider"; expect_unchanged([&] { cache.upsert_account(provider_reassigned); }, "account provider identity reassignment accepted"); auto duplicate_calendar = calendar(); expect_unchanged( [&] { cache.replace_calendars_after_complete_listing( "account-a", std::vector{duplicate_calendar, duplicate_calendar}); }, "duplicate calendar ID accepted"); auto duplicate_remote_calendar = calendar("calendar-new"); expect_unchanged( [&] { cache.replace_calendars_after_complete_listing("account-a", std::vector{duplicate_calendar, duplicate_remote_calendar}); }, "duplicate calendar remote ID accepted"); auto empty_calendar = calendar(); empty_calendar.id.clear(); expect_unchanged( [&] { cache.replace_calendars_after_complete_listing( "account-a", std::vector{empty_calendar}); }, "empty calendar ID accepted"); auto wrong_account = calendar(); wrong_account.account_id = "account-b"; expect_unchanged( [&] { cache.replace_calendars_after_complete_listing( "account-a", std::vector{wrong_account}); }, "cross-account calendar accepted"); auto reused_calendar_id = calendar("calendar-a", "account-b"); reused_calendar_id.remote_id = "different-remote-calendar"; expect_unchanged( [&] { cache.replace_calendars_after_complete_listing( "account-b", std::vector{reused_calendar_id}); }, "calendar local ID was reassigned across accounts"); auto inactive_input = calendar(); inactive_input.active = false; expect_unchanged( [&] { cache.replace_calendars_after_complete_listing( "account-a", std::vector{inactive_input}); }, "inactive completed-listing input accepted"); auto missing_raw_calendar = calendar(); missing_raw_calendar.raw_payload.clear(); expect_unchanged( [&] { cache.replace_calendars_after_complete_listing( "account-a", std::vector{missing_raw_calendar}); }, "calendar without raw payload accepted"); auto duplicate_event = event(); expect_unchanged( [&] { cache.apply_pull_page(PullPage{{duplicate_event, duplicate_event}, {}, checkpoint("duplicate-cursor")}); }, "duplicate event ID accepted"); auto duplicate_remote_event = event("event-new"); expect_unchanged( [&] { cache.apply_pull_page(PullPage{{duplicate_event, duplicate_remote_event}, {}, checkpoint("duplicate-remote-event")}); }, "duplicate event remote ID accepted"); auto empty_event = event(); empty_event.id.clear(); expect_unchanged( [&] { cache.apply_pull_page(page(empty_event, instance(), checkpoint("empty-event"))); }, "empty event ID accepted"); auto cross_calendar_event = event(); cross_calendar_event.calendar_id = "calendar-b"; expect_unchanged( [&] { cache.apply_pull_page( PullPage{{cross_calendar_event}, {}, checkpoint("cross-calendar")}); }, "cross-calendar event accepted"); auto reassigned_event = event("event-a", "calendar-b"); reassigned_event.remote_id = "different-remote-event"; expect_unchanged( [&] { cache.apply_pull_page(PullPage{{reassigned_event}, {}, checkpoint("reassigned-event", "calendar-b")}); }, "event local ID was reassigned across calendars"); auto missing_remote = event(); missing_remote.remote_id.clear(); expect_unchanged( [&] { cache.apply_pull_page(page(missing_remote, instance(), checkpoint("remote"))); }, "event without remote ID accepted"); auto missing_raw_event = event(); missing_raw_event.raw_payload.clear(); expect_unchanged( [&] { cache.apply_pull_page(page(missing_raw_event, instance(), checkpoint("raw"))); }, "nondeleted event without raw payload accepted"); auto duplicate_instance = instance(); expect_unchanged( [&] { cache.apply_pull_page(PullPage{{event()}, {duplicate_instance, duplicate_instance}, checkpoint("duplicate-instance")}); }, "duplicate instance ID accepted"); auto empty_instance = instance(); empty_instance.id.clear(); expect_unchanged( [&] { cache.apply_pull_page(page(event(), empty_instance, checkpoint("empty-instance"))); }, "empty instance ID accepted"); auto orphan = instance(); orphan.event_id = "event-not-in-page"; expect_unchanged( [&] { cache.apply_pull_page(page(event(), orphan, checkpoint("orphan"))); }, "orphan instance accepted"); auto backwards = instance(); backwards.end_epoch_microseconds = backwards.start_epoch_microseconds - 1; expect_unchanged( [&] { cache.apply_pull_page(page(event(), backwards, checkpoint("backwards"))); }, "backwards instance interval accepted"); auto empty_all_day = instance(); empty_all_day.all_day = true; empty_all_day.end_epoch_microseconds = empty_all_day.start_epoch_microseconds; expect_unchanged( [&] { cache.apply_pull_page(page(event(), empty_all_day, checkpoint("all-day"))); }, "empty all-day interval accepted"); auto deleted_with_instance = event(); deleted_with_instance.deleted = true; deleted_with_instance.raw_payload.clear(); expect_unchanged( [&] { cache.apply_pull_page( page(deleted_with_instance, instance(), checkpoint("deleted-instance"))); }, "instance attached to tombstoned event accepted"); auto negative = event(); negative.id = "event-negative"; negative.remote_id = "remote-negative"; negative.uid = "uid-negative"; auto before_epoch = instance("instance-negative", "event-negative"); before_epoch.start_epoch_microseconds = -2'000'000; before_epoch.end_epoch_microseconds = -1'000'000; cache.apply_pull_page(page(negative, before_epoch, checkpoint("negative"))); require(std::ranges::any_of(cache.snapshot().instances, [](const CachedEventInstance& value) { return value.id == "instance-negative" && value.start_epoch_microseconds < 0; }), "valid pre-1970 instance was rejected"); } void test_sql_failure_rolls_back_snapshot_and_cursor(const std::filesystem::path& root) { SyncCache cache{root / "sql-rollback.db"}; seed_calendar(cache); auto event_b = event("event-b"); event_b.remote_id = "remote-event-b"; event_b.uid = "uid-b"; event_b.raw_payload = R"({"event":"b"})"; auto instance_b = instance("instance-b", "event-b"); cache.apply_pull_page( PullPage{{event(), event_b}, {instance(), instance_b}, checkpoint("baseline-cursor")}); const auto baseline = cache.snapshot(); auto changed = event(); changed.etag = "must-roll-back"; changed.raw_payload = R"({"event":"must-roll-back"})"; auto colliding_instance = instance("instance-b", "event-a"); require_throws( [&] { cache.apply_pull_page( page(changed, colliding_instance, checkpoint("must-not-advance"))); }, "cross-event instance ID collision was accepted"); require(cache.snapshot() == baseline, "SQL failure did not roll back event, instance, and cursor state together"); } void test_checkpoint_calendar_and_shape_validation(const std::filesystem::path& root) { const auto path = root / "checkpoint-validation.db"; SyncCache cache{path}; seed_calendar(cache); auto expect_empty = [&](auto&& operation, std::string_view message) { require_throws(std::forward(operation), message); require(cache.snapshot().events.empty() && cache.snapshot().checkpoints.empty(), "invalid pull partially changed event or checkpoint state"); }; expect_empty( [&] { cache.apply_pull_page(page(event(), instance(), checkpoint("", "calendar-a"))); }, "empty checkpoint cursor accepted"); expect_empty( [&] { cache.apply_pull_page(page(event(), instance(), checkpoint("cursor", "calendar-a", "only-start", ""))); }, "half-specified checkpoint window accepted"); expect_empty( [&] { cache.apply_pull_page(page(event(), instance(), checkpoint("cursor", "calendar-a", "", "only-end"))); }, "half-specified checkpoint window accepted"); expect_empty( [&] { cache.apply_pull_page( page(event(), instance(), checkpoint("cursor", "missing-calendar"))); }, "missing checkpoint calendar accepted"); cache.apply_pull_page(PullPage{{}, {}, checkpoint("empty-window", "calendar-a", "", "")}); require(cache.snapshot().checkpoints.size() == 1 && cache.snapshot().checkpoints[0].window_start.empty() && cache.snapshot().checkpoints[0].window_end.empty(), "valid unwindowed opaque checkpoint was rejected"); cache.replace_calendars_after_complete_listing("account-a", {}); require(!cache.snapshot().calendars[0].active, "fixture calendar was not deactivated"); require_throws( [&] { cache.apply_pull_page(page()); }, "inactive checkpoint calendar accepted"); require(cache.snapshot().events.empty(), "inactive-calendar pull changed event state"); } void test_foreign_key_failure_rolls_back(const std::filesystem::path& root) { const auto path = root / "foreign-key.db"; SyncCache cache{path}; seed_calendar(cache); cache.apply_pull_page(page()); const auto baseline = cache.snapshot(); SqliteConnection external{path}; external.execute("PRAGMA foreign_keys=ON"); require_throws( [&] { external.execute("DELETE FROM accounts WHERE id='account-a'"); }, "foreign-key violating direct delete succeeded"); require(cache.snapshot() == baseline, "foreign-key failure changed cached data"); } void test_migration_failures_are_non_destructive(const std::filesystem::path& root) { const auto collision_path = root / "collision.db"; { SqliteConnection database{collision_path}; database.execute("CREATE TABLE accounts (sentinel TEXT NOT NULL)"); database.execute("INSERT INTO accounts VALUES ('keep-me')"); database.execute("PRAGMA user_version=0"); } require_throws([&] { SyncCache cache{collision_path}; }, "incompatible migration collision was accepted"); { SqliteConnection database{collision_path}; require(database.scalar_int("PRAGMA user_version") == 0, "failed migration changed schema version"); require(database.scalar_text("SELECT sentinel FROM accounts") == "keep-me", "failed migration changed preexisting data"); require(database.scalar_int( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name!='accounts'") == 0, "failed migration left partially created v1 tables"); } const auto future_path = root / "future.db"; { SqliteConnection database{future_path}; database.execute("CREATE TABLE future_sentinel (value TEXT NOT NULL)"); database.execute("INSERT INTO future_sentinel VALUES ('future-data')"); database.execute("PRAGMA user_version=2"); } require_throws([&] { SyncCache cache{future_path}; }, "future schema version was accepted"); { SqliteConnection database{future_path}; require(database.scalar_int("PRAGMA user_version") == 2, "future schema rejection changed schema version"); require(database.scalar_text("SELECT value FROM future_sentinel") == "future-data", "future schema rejection changed data"); require(database.scalar_int("SELECT COUNT(*) FROM sqlite_master WHERE type='table'") == 1, "future schema rejection created v1 tables"); } } void test_invalid_files_are_refused(const std::filesystem::path& root) { const auto corrupt_path = root / "corrupt.db"; { std::ofstream output(corrupt_path, std::ios::binary); output << "this is not a SQLite database"; require(static_cast(output), "failed to write corrupt database fixture"); } const std::string before = [&] { std::ifstream input(corrupt_path, std::ios::binary); return std::string{std::istreambuf_iterator{input}, {}}; }(); require_throws([&] { SyncCache cache{corrupt_path}; }, "non-SQLite cache file was accepted"); std::ifstream after_input(corrupt_path, std::ios::binary); const std::string after{std::istreambuf_iterator{after_input}, {}}; require(after == before, "corrupt cache rejection changed the source file"); #if !defined(_WIN32) const auto target_path = root / "symlink-target.db"; { SqliteConnection target{target_path}; } const auto symlink_path = root / "symlink.db"; require(::symlink(target_path.c_str(), symlink_path.c_str()) == 0, "failed to create symlink fixture"); require_throws([&] { SyncCache cache{symlink_path}; }, "symlink cache path was accepted"); const auto directory_path = root / "directory.db"; std::filesystem::create_directory(directory_path); require_throws([&] { SyncCache cache{directory_path}; }, "nonregular cache path was accepted"); #endif } } // namespace int main() { try { TempDirectory temporary; test_fresh_schema_permissions_and_secrets(temporary.path()); test_existing_permissions_are_tightened(temporary.path()); test_account_calendars_and_deterministic_snapshot(temporary.path()); test_pull_pages_replacement_tombstone_and_windows(temporary.path()); test_multiple_connections_serialize(temporary.path()); test_input_validation_and_rollback(temporary.path()); test_sql_failure_rolls_back_snapshot_and_cursor(temporary.path()); test_checkpoint_calendar_and_shape_validation(temporary.path()); test_foreign_key_failure_rolls_back(temporary.path()); test_migration_failures_are_non_destructive(temporary.path()); test_invalid_files_are_refused(temporary.path()); } catch (const std::exception& error) { std::cerr << "sync cache test failure: " << error.what() << '\n'; return 1; } std::cout << "sync cache tests passed\n"; return 0; }