Read documented Graph v1 calendarView pages for secondary calendars and publish each finite window only after the complete bounded listing validates. Add schema-v2 per-window event membership so moved-out occurrences are evicted without being mislabeled as remote tombstones, while overlapping windows retain shared instances. Verified: 18/18 fresh normal, 18/18 -Werror, and 18/18 ASan/UBSan tests.
1104 lines
46 KiB
C++
1104 lines
46 KiB
C++
#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::CompleteWindow;
|
|
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)};
|
|
}
|
|
|
|
CompleteWindow complete_window(std::vector<CachedEvent> events = {event()},
|
|
std::vector<CachedEventInstance> instances = {instance()},
|
|
SyncCheckpoint complete_checkpoint = checkpoint()) {
|
|
complete_checkpoint.complete = true;
|
|
return {std::move(events), std::move(instances), std::move(complete_checkpoint)};
|
|
}
|
|
|
|
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", "event_window_membership", "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",
|
|
"event_window_membership", "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));
|
|
}
|
|
require(inspection.scalar_int(
|
|
"SELECT COUNT(*) FROM pragma_index_list('event_window_membership') "
|
|
"WHERE name='event_window_membership_event_id_idx'")
|
|
== 1,
|
|
"complete-window event lookup index is missing");
|
|
|
|
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_complete_window_idempotence_revival_and_omission(
|
|
const std::filesystem::path& root) {
|
|
const auto path = root / "complete-window.db";
|
|
SyncCache cache{path};
|
|
seed_calendar(cache);
|
|
auto completed = complete_window();
|
|
cache.replace_complete_window(completed);
|
|
const auto initial = cache.snapshot();
|
|
require(initial.events.size() == 1 && initial.instances.size() == 1
|
|
&& initial.checkpoints.size() == 1 && initial.checkpoints[0].complete,
|
|
"complete window did not persist event, instance, and complete checkpoint");
|
|
cache.replace_complete_window(completed);
|
|
require(cache.snapshot() == initial, "complete window replacement was not idempotent");
|
|
|
|
auto tombstone = event();
|
|
tombstone.deleted = true;
|
|
tombstone.raw_payload.clear();
|
|
cache.apply_pull_page(PullPage{{tombstone}, {}, checkpoint("delta-tombstone")});
|
|
require(cache.snapshot().events[0].deleted && cache.snapshot().instances.empty(),
|
|
"delta tombstone fixture did not delete the event");
|
|
|
|
auto revived = event();
|
|
revived.etag = "revived-etag";
|
|
revived.raw_payload = R"({"event":"revived"})";
|
|
auto revived_instance = instance("instance-revived");
|
|
auto revival_checkpoint = checkpoint("complete-revival");
|
|
revival_checkpoint.complete = true;
|
|
cache.replace_complete_window({{revived}, {revived_instance}, revival_checkpoint});
|
|
auto snapshot = cache.snapshot();
|
|
require(!snapshot.events[0].deleted && snapshot.events[0].etag == "revived-etag"
|
|
&& snapshot.events[0].raw_payload == revived.raw_payload
|
|
&& snapshot.instances.size() == 1
|
|
&& snapshot.instances[0].id == "instance-revived",
|
|
"returned complete-window event was not revived and replaced");
|
|
|
|
auto empty_checkpoint = checkpoint("complete-empty");
|
|
empty_checkpoint.complete = true;
|
|
cache.replace_complete_window({{}, {}, empty_checkpoint});
|
|
snapshot = cache.snapshot();
|
|
require(snapshot.events.size() == 1 && !snapshot.events[0].deleted
|
|
&& snapshot.events[0].raw_payload == revived.raw_payload
|
|
&& snapshot.instances.empty(),
|
|
"complete-window omission acted as a tombstone or retained last-window instances");
|
|
require(snapshot.checkpoints[0].cursor == "complete-empty"
|
|
&& snapshot.checkpoints[0].complete,
|
|
"empty completed window did not advance its checkpoint");
|
|
|
|
SqliteConnection inspection{path};
|
|
require(inspection.scalar_int("SELECT COUNT(*) FROM event_window_membership") == 0,
|
|
"empty completed window retained membership rows");
|
|
}
|
|
|
|
void test_overlapping_complete_windows_preserve_instances(
|
|
const std::filesystem::path& root) {
|
|
const auto path = root / "overlapping-windows.db";
|
|
SyncCache cache{path};
|
|
seed_calendar(cache);
|
|
auto first_checkpoint = checkpoint("first-complete", "calendar-a", "first-start", "first-end");
|
|
first_checkpoint.complete = true;
|
|
cache.replace_complete_window({{event()}, {instance()}, first_checkpoint});
|
|
|
|
auto second_instance = instance("instance-second");
|
|
second_instance.title = "Second window representation";
|
|
auto second_checkpoint =
|
|
checkpoint("second-complete", "calendar-a", "second-start", "second-end");
|
|
second_checkpoint.complete = true;
|
|
cache.replace_complete_window({{event()}, {second_instance}, second_checkpoint});
|
|
require(cache.snapshot().instances.size() == 1
|
|
&& cache.snapshot().instances[0].id == "instance-second",
|
|
"overlapping returned event did not replace its instances");
|
|
|
|
first_checkpoint.cursor = "first-empty";
|
|
cache.replace_complete_window({{}, {}, first_checkpoint});
|
|
require(cache.snapshot().instances.size() == 1
|
|
&& cache.snapshot().instances[0].id == "instance-second",
|
|
"omission from one overlapping window evicted a still-referenced event");
|
|
|
|
second_checkpoint.cursor = "second-empty";
|
|
cache.replace_complete_window({{}, {}, second_checkpoint});
|
|
require(cache.snapshot().instances.empty(),
|
|
"removing the final complete-window membership did not evict instances");
|
|
require(cache.snapshot().events.size() == 1 && !cache.snapshot().events[0].deleted,
|
|
"final membership removal deleted or tombstoned the event row");
|
|
|
|
SqliteConnection inspection{path};
|
|
require_throws(
|
|
[&] {
|
|
inspection.execute("PRAGMA foreign_keys=ON");
|
|
inspection.execute(
|
|
"INSERT INTO accounts VALUES('other-account','provider','subject','Other')");
|
|
inspection.execute(
|
|
"INSERT INTO calendars VALUES('other-calendar','other-account','remote','Other','',0,1,'raw')");
|
|
inspection.execute(
|
|
"INSERT INTO event_window_membership VALUES('other-calendar','start','end','event-a')");
|
|
},
|
|
"cross-calendar membership bypassed the composite foreign key");
|
|
}
|
|
|
|
void test_complete_window_validation_and_sql_rollback(const std::filesystem::path& root) {
|
|
SyncCache cache{root / "complete-validation.db"};
|
|
seed_calendar(cache);
|
|
auto event_b = event("event-b");
|
|
event_b.remote_id = "remote-event-b";
|
|
event_b.raw_payload = R"({"event":"b"})";
|
|
auto instance_b = instance("instance-b", "event-b");
|
|
cache.replace_complete_window(complete_window({event(), event_b}, {instance(), instance_b}));
|
|
const auto baseline = cache.snapshot();
|
|
|
|
auto expect_unchanged = [&](auto&& operation, std::string_view message) {
|
|
require_throws(std::forward<decltype(operation)>(operation), message);
|
|
require(cache.snapshot() == baseline, "invalid complete window changed cache state");
|
|
};
|
|
|
|
auto incomplete = complete_window();
|
|
incomplete.checkpoint.complete = false;
|
|
expect_unchanged([&] { cache.replace_complete_window(incomplete); },
|
|
"incomplete complete-window checkpoint accepted");
|
|
for (const int missing_field : {0, 1, 2, 3}) {
|
|
auto invalid = complete_window();
|
|
if (missing_field == 0) invalid.checkpoint.calendar_id.clear();
|
|
if (missing_field == 1) invalid.checkpoint.window_start.clear();
|
|
if (missing_field == 2) invalid.checkpoint.window_end.clear();
|
|
if (missing_field == 3) invalid.checkpoint.cursor.clear();
|
|
expect_unchanged([&] { cache.replace_complete_window(invalid); },
|
|
"empty complete-window checkpoint field accepted");
|
|
}
|
|
auto deleted = event();
|
|
deleted.deleted = true;
|
|
expect_unchanged([&] { cache.replace_complete_window(complete_window({deleted}, {})); },
|
|
"deleted complete-window event accepted");
|
|
auto cross_calendar = event();
|
|
cross_calendar.calendar_id = "other-calendar";
|
|
expect_unchanged(
|
|
[&] { cache.replace_complete_window(complete_window({cross_calendar}, {})); },
|
|
"cross-calendar complete-window event accepted");
|
|
expect_unchanged(
|
|
[&] { cache.replace_complete_window(complete_window({event(), event()}, {})); },
|
|
"duplicate complete-window event accepted");
|
|
auto duplicate_remote = event("different-id");
|
|
expect_unchanged(
|
|
[&] { cache.replace_complete_window(complete_window({event(), duplicate_remote}, {})); },
|
|
"duplicate complete-window remote event accepted");
|
|
auto orphan = instance();
|
|
orphan.event_id = "not-returned";
|
|
expect_unchanged([&] { cache.replace_complete_window(complete_window({event()}, {orphan})); },
|
|
"orphan complete-window instance accepted");
|
|
auto invalid_interval = instance();
|
|
invalid_interval.end_epoch_microseconds = invalid_interval.start_epoch_microseconds - 1;
|
|
expect_unchanged(
|
|
[&] { cache.replace_complete_window(complete_window({event()}, {invalid_interval})); },
|
|
"invalid complete-window instance interval accepted");
|
|
|
|
auto changed = event();
|
|
changed.etag = "must-roll-back";
|
|
changed.raw_payload = R"({"event":"must-roll-back"})";
|
|
auto collision = instance("instance-b", "event-a");
|
|
auto collision_checkpoint = checkpoint("must-not-advance");
|
|
collision_checkpoint.complete = true;
|
|
expect_unchanged(
|
|
[&] { cache.replace_complete_window({{changed}, {collision}, collision_checkpoint}); },
|
|
"complete-window SQL instance collision was accepted");
|
|
|
|
cache.replace_calendars_after_complete_listing("account-a", {});
|
|
const auto inactive_baseline = cache.snapshot();
|
|
require_throws([&] { cache.replace_complete_window(complete_window()); },
|
|
"inactive calendar accepted a complete window");
|
|
require(cache.snapshot() == inactive_baseline,
|
|
"inactive-calendar complete window changed cache state");
|
|
}
|
|
|
|
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 downgrade_fixture_to_v1(const std::filesystem::path& path) {
|
|
SqliteConnection database{path};
|
|
database.execute("DROP TABLE event_window_membership");
|
|
database.execute("DROP INDEX events_id_calendar_id_unique_idx");
|
|
database.execute("PRAGMA user_version=1");
|
|
}
|
|
|
|
void test_v1_to_v2_migration_preserves_data_and_serializes(
|
|
const std::filesystem::path& root) {
|
|
const auto path = root / "v1-upgrade.db";
|
|
CacheSnapshot before;
|
|
{
|
|
SyncCache cache{path};
|
|
seed_calendar(cache);
|
|
cache.apply_pull_page(page());
|
|
before = cache.snapshot();
|
|
}
|
|
downgrade_fixture_to_v1(path);
|
|
{
|
|
SyncCache upgraded{path};
|
|
require(upgraded.snapshot() == before, "v1 to v2 migration changed cached data");
|
|
}
|
|
{
|
|
SqliteConnection inspection{path};
|
|
require(inspection.scalar_int("PRAGMA user_version") == 2,
|
|
"v1 migration did not record schema version 2");
|
|
require(inspection.scalar_int(
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' "
|
|
"AND name='event_window_membership'")
|
|
== 1,
|
|
"v1 migration did not create complete-window membership");
|
|
}
|
|
|
|
const auto concurrent_path = root / "v1-concurrent.db";
|
|
{
|
|
SyncCache cache{concurrent_path};
|
|
seed_calendar(cache);
|
|
}
|
|
downgrade_fixture_to_v1(concurrent_path);
|
|
std::atomic<bool> begin{false};
|
|
std::exception_ptr first_error;
|
|
std::exception_ptr second_error;
|
|
auto open = [&](std::exception_ptr& error) {
|
|
while (!begin.load(std::memory_order_acquire)) {
|
|
std::this_thread::yield();
|
|
}
|
|
try {
|
|
SyncCache cache{concurrent_path};
|
|
(void)cache.snapshot();
|
|
} catch (...) {
|
|
error = std::current_exception();
|
|
}
|
|
};
|
|
std::thread first{open, std::ref(first_error)};
|
|
std::thread second{open, std::ref(second_error)};
|
|
begin.store(true, std::memory_order_release);
|
|
first.join();
|
|
second.join();
|
|
require(first_error == nullptr && second_error == nullptr,
|
|
"concurrent v1 opens did not serialize schema migration");
|
|
SqliteConnection concurrent_inspection{concurrent_path};
|
|
require(concurrent_inspection.scalar_int("PRAGMA user_version") == 2,
|
|
"concurrent migration did not finish at schema version 2");
|
|
}
|
|
|
|
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 v1_collision_path = root / "v1-collision.db";
|
|
{
|
|
SyncCache cache{v1_collision_path};
|
|
seed_calendar(cache);
|
|
cache.apply_pull_page(page());
|
|
}
|
|
downgrade_fixture_to_v1(v1_collision_path);
|
|
{
|
|
SqliteConnection database{v1_collision_path};
|
|
database.execute("CREATE TABLE event_window_membership (sentinel TEXT NOT NULL)");
|
|
database.execute("INSERT INTO event_window_membership VALUES ('keep-v1')");
|
|
}
|
|
require_throws([&] { SyncCache cache{v1_collision_path}; },
|
|
"incompatible v1 to v2 migration collision was accepted");
|
|
{
|
|
SqliteConnection database{v1_collision_path};
|
|
require(database.scalar_int("PRAGMA user_version") == 1,
|
|
"failed v2 migration changed schema version");
|
|
require(database.scalar_text("SELECT sentinel FROM event_window_membership") == "keep-v1",
|
|
"failed v2 migration changed preexisting data");
|
|
require(database.scalar_int(
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' "
|
|
"AND name='events_id_calendar_id_unique_idx'")
|
|
== 0,
|
|
"failed v2 migration left a partially created index");
|
|
require(database.scalar_int("SELECT COUNT(*) FROM events") == 1,
|
|
"failed v2 migration changed v1 event data");
|
|
}
|
|
|
|
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=3");
|
|
}
|
|
require_throws([&] { SyncCache cache{future_path}; }, "future schema version was accepted");
|
|
{
|
|
SqliteConnection database{future_path};
|
|
require(database.scalar_int("PRAGMA user_version") == 3,
|
|
"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_complete_window_idempotence_revival_and_omission(temporary.path());
|
|
test_overlapping_complete_windows_preserve_instances(temporary.path());
|
|
test_complete_window_validation_and_sql_rollback(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_v1_to_v2_migration_preserves_data_and_serializes(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;
|
|
}
|