feat: add durable provider cache
Add a private provider-neutral SQLite WAL cache with transactional migrations, retained raw payloads and tombstones, per-window opaque checkpoints, and reserved outbox/conflict tables. Verify migrations, permissions, identity invariants, concurrent access, atomic pull-page rollback, and future-schema rejection with deterministic tests. No OAuth, networking, secrets, or Graph synchronization is included yet.
This commit is contained in:
820
tests/sync_cache_tests.cpp
Normal file
820
tests/sync_cache_tests.cpp
Normal file
@@ -0,0 +1,820 @@
|
||||
#include "nocal/storage/sync_cache.hpp"
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cerrno>
|
||||
#include <exception>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <iostream>
|
||||
#include <iterator>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#if !defined(_WIN32)
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#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 <typename Function>
|
||||
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<char> 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<const char*>(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<std::string> 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<std::string> 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<const char*>(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<std::string>& 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<void>(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<CachedCalendar>{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<CachedCalendar>{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<std::string>{replaced.instances[0].id, replaced.instances[1].id},
|
||||
"instance-a-2")
|
||||
&& contains(std::vector<std::string>{
|
||||
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<bool> 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<decltype(operation)>(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<CachedCalendar>{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<CachedCalendar>{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<CachedCalendar>{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<CachedCalendar>{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<CachedCalendar>{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<CachedCalendar>{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<CachedCalendar>{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<decltype(operation)>(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<bool>(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<char>{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<char>{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;
|
||||
}
|
||||
Reference in New Issue
Block a user