feat(sync): reconcile secondary Graph calendars

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.
This commit is contained in:
2026-07-18 13:05:21 +01:00
parent 0582444c61
commit 98e07e287e
11 changed files with 1520 additions and 60 deletions

View File

@@ -27,8 +27,10 @@ namespace {
using namespace std::chrono;
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;
@@ -43,8 +45,10 @@ using nocal::sync::MicrosoftGraphWindow;
constexpr std::string_view graph_prefix = "https://graph.microsoft.com/";
constexpr std::string_view primary_remote_id = "remote-primary-calendar";
constexpr std::string_view secondary_remote_id = "remote-secondary/id +?%";
constexpr std::string_view primary_local_prefix = "graph-calendar-";
constexpr std::string_view event_local_prefix = "graph-event-";
constexpr std::string_view instance_local_prefix = "graph-instance-";
void require(bool condition, std::string_view message) {
if (!condition) {
@@ -183,12 +187,31 @@ struct Rig {
return page.dump();
}
[[nodiscard]] std::string view_page(const nlohmann::json& events,
std::optional<std::string> next = std::nullopt,
std::optional<std::string> delta = std::nullopt) {
nlohmann::json page{{"value", events}};
if (next) {
page["@odata.nextLink"] = *next;
}
if (delta) {
page["@odata.deltaLink"] = *delta;
}
return page.dump();
}
[[nodiscard]] MicrosoftGraphWindow july_window() {
const auto start = sys_days{year{2026} / July / 18};
return {duration_cast<microseconds>(start.time_since_epoch()).count(),
duration_cast<microseconds>((start + days{1}).time_since_epoch()).count()};
}
[[nodiscard]] MicrosoftGraphWindow window_from_days(
year_month_day start, year_month_day end) {
return {duration_cast<microseconds>(sys_days{start}.time_since_epoch()).count(),
duration_cast<microseconds>(sys_days{end}.time_since_epoch()).count()};
}
[[nodiscard]] const HttpHeader* header(const HttpRequest& request, std::string_view name) {
const auto found = std::ranges::find_if(request.headers, [&](const HttpHeader& value) {
if (value.name.size() != name.size()) {
@@ -273,6 +296,70 @@ void seed_calendars(Rig& rig, bool include_other = true) {
return std::string{event_local_prefix} + sha256_hex(input);
}
[[nodiscard]] std::string local_instance_id(std::string_view event_id) {
std::string input{"instance", 8};
input.push_back('\0');
input += event_id;
return std::string{instance_local_prefix} + sha256_hex(input);
}
[[nodiscard]] std::string percent_encode(std::string_view value) {
constexpr char hexadecimal[] = "0123456789ABCDEF";
std::string encoded;
for (const unsigned char character : value) {
const bool unreserved = (character >= 'A' && character <= 'Z')
|| (character >= 'a' && character <= 'z')
|| (character >= '0' && character <= '9') || character == '-'
|| character == '.' || character == '_' || character == '~';
if (unreserved) {
encoded.push_back(static_cast<char>(character));
} else {
encoded.push_back('%');
encoded.push_back(hexadecimal[character >> 4U]);
encoded.push_back(hexadecimal[character & 0x0fU]);
}
}
return encoded;
}
[[nodiscard]] std::string secondary_view_prefix(std::string_view remote) {
return "https://graph.microsoft.com/v1.0/me/calendars/" + percent_encode(remote)
+ "/calendarView";
}
[[nodiscard]] std::string secondary_next(
std::string_view remote, std::string_view marker = "page-2") {
return secondary_view_prefix(remote) + "?$skiptoken=" + std::string{marker};
}
[[nodiscard]] const CachedCalendar& calendar_with_remote(
const CacheSnapshot& snapshot, std::string_view remote) {
const auto found = std::ranges::find_if(snapshot.calendars,
[&](const CachedCalendar& calendar) { return calendar.remote_id == remote; });
if (found == snapshot.calendars.end()) {
throw std::runtime_error("calendar fixture was not cached");
}
return *found;
}
[[nodiscard]] std::string seed_secondary_calendar(
Rig& rig, std::string_view remote = secondary_remote_id) {
seed_account(rig);
const nlohmann::json values = nlohmann::json::array(
{calendar_json(primary_remote_id, "Primary", true),
calendar_json(remote, "Secondary", false, false)});
rig.transport.respond(calendar_page(values));
rig.graph.refresh_calendars(rig.credentials);
return calendar_with_remote(rig.cache.snapshot(), remote).id;
}
void sync_secondary_page(Rig& rig, std::string_view calendar_id,
const nlohmann::json& events, const MicrosoftGraphWindow& window = july_window()) {
rig.transport.respond(view_page(events));
rig.graph.sync_secondary_calendar_window(
rig.credentials, std::string{calendar_id}, window);
}
void test_credentials_and_request_security(const std::filesystem::path& root) {
const std::vector<MicrosoftGraphCredentials> invalid{
{"", "token"}, {"bad\naccount", "token"}, {"SENSITIVE_ACCOUNT_ID_123", ""},
@@ -670,6 +757,356 @@ void test_cache_failure_does_not_advance_cursor(const std::filesystem::path& roo
"cache failure advanced Graph cursor or partially replaced event state");
}
void test_secondary_exact_route_and_preconditions(const std::filesystem::path& root) {
{
Rig rig{root};
const std::string secondary_id = seed_secondary_calendar(rig);
const std::size_t request_index = rig.transport.requests.size();
sync_secondary_page(rig, secondary_id, nlohmann::json::array());
require(rig.transport.requests.size() == request_index + 1,
"secondary full-window sync sent an unexpected request count");
const HttpRequest& request = rig.transport.requests[request_index];
require_safe_request(request, rig.credentials.access_token);
const std::string expected = secondary_view_prefix(secondary_remote_id)
+ "?startDateTime=2026-07-18T00%3A00%3A00.000000Z&"
"endDateTime=2026-07-19T00%3A00%3A00.000000Z&$top=1000";
require(request.url == expected,
"secondary sync did not use the exact encoded Graph v1 calendarView route");
require(request.url.find("/beta/") == std::string::npos
&& request.url.find("/delta") == std::string::npos,
"secondary sync used beta or a delta route");
const HttpHeader* prefer = header(request, "Prefer");
require(prefer != nullptr && prefer->value.find("UTC") != std::string::npos
&& prefer->value.find("ImmutableId") != std::string::npos,
"secondary sync omitted the UTC/immutable-ID preference");
}
auto require_no_request = [](Rig& rig, std::string calendar_id,
std::string_view message) {
const std::size_t before_requests = rig.transport.requests.size();
const CacheSnapshot before = rig.cache.snapshot();
const std::array<std::string_view, 2> sensitive{
rig.credentials.access_token, calendar_id};
require_throws_redacted(
[&] {
rig.graph.sync_secondary_calendar_window(
rig.credentials, calendar_id, july_window());
},
message, sensitive);
require(rig.transport.requests.size() == before_requests,
std::string{message}.append(": precondition reached the transport"));
require(rig.cache.snapshot() == before,
std::string{message}.append(": precondition mutated cache"));
};
{
Rig rig{root};
static_cast<void>(seed_secondary_calendar(rig));
const std::string primary_id =
calendar_with_remote(rig.cache.snapshot(), primary_remote_id).id;
require_no_request(rig, primary_id, "primary calendar accepted as secondary");
require_no_request(rig, "missing-calendar", "missing secondary calendar accepted");
}
{
Rig rig{root};
static_cast<void>(seed_secondary_calendar(rig));
const CachedAccount other{
"other-account", "microsoft-graph", "other-subject", "Other"};
rig.cache.upsert_account(other);
const std::string other_id = local_calendar_id(other.id, "other-remote");
const CachedCalendar other_calendar{other_id, other.id, "other-remote", "Other", "",
true, true, calendar_json("other-remote", "Other", false, false).dump()};
rig.cache.replace_calendars_after_complete_listing(other.id, {&other_calendar, 1});
require_no_request(rig, other_id, "wrong-account secondary calendar accepted");
}
{
Rig rig{root};
const std::string secondary_id = seed_secondary_calendar(rig);
rig.transport.respond(calendar_page(
nlohmann::json::array({calendar_json(primary_remote_id, "Primary", true)})));
rig.graph.refresh_calendars(rig.credentials);
require_no_request(rig, secondary_id, "inactive secondary calendar accepted");
}
{
Rig rig{root};
seed_account(rig);
const std::string malformed_id =
local_calendar_id(rig.credentials.account_id, "malformed-remote");
const CachedCalendar malformed{malformed_id, rig.credentials.account_id,
"malformed-remote", "Malformed", "", true, true, "{"};
rig.cache.replace_calendars_after_complete_listing(
rig.credentials.account_id, {&malformed, 1});
require_no_request(rig, malformed_id, "malformed cached calendar accepted");
}
}
void test_secondary_partial_pages_are_atomic_and_redacted(
const std::filesystem::path& root) {
for (int failure = 0; failure < 3; ++failure) {
Rig rig{root};
const std::string secondary_id = seed_secondary_calendar(rig);
sync_secondary_page(rig, secondary_id,
nlohmann::json::array({live_event("baseline-secondary")}));
const CacheSnapshot baseline = rig.cache.snapshot();
const std::string next = secondary_next(secondary_remote_id);
const std::string partial_remote = "SENSITIVE_PARTIAL_REMOTE_ID";
const std::string first_body =
view_page(nlohmann::json::array({live_event(partial_remote)}), next);
rig.transport.respond(first_body);
const std::string later_body = "SENSITIVE_LATER_PAGE_BODY";
if (failure == 0) {
rig.transport.fail("transport leaked " + rig.credentials.access_token + " "
+ std::string{secondary_remote_id} + " " + next);
} else if (failure == 1) {
rig.transport.respond(later_body, 503);
} else {
rig.transport.respond("{\"value\":[");
}
const std::size_t requests_before = rig.transport.requests.size();
const std::string encoded_remote = percent_encode(secondary_remote_id);
const std::array<std::string_view, 7> sensitive{rig.credentials.access_token,
secondary_remote_id, encoded_remote, partial_remote, first_body, later_body, next};
require_throws_redacted(
[&] {
rig.graph.sync_secondary_calendar_window(
rig.credentials, secondary_id, july_window());
},
"partial secondary listing failure was accepted", sensitive);
require(rig.transport.requests.size() == requests_before + 2,
"partial secondary listing failure sent the wrong request count");
require(rig.cache.snapshot() == baseline,
"partial secondary listing changed event, membership, or checkpoint state");
}
}
void test_secondary_continuation_security(const std::filesystem::path& root) {
const std::string exact_prefix = secondary_view_prefix(secondary_remote_id);
const std::vector<std::string> unsafe{
"http://graph.microsoft.com/v1.0/me/calendars/"
+ percent_encode(secondary_remote_id) + "/calendarView?$skiptoken=x",
"https://evil.example.test/v1.0/me/calendars/"
+ percent_encode(secondary_remote_id) + "/calendarView?$skiptoken=x",
"https://user@graph.microsoft.com/v1.0/me/calendars/"
+ percent_encode(secondary_remote_id) + "/calendarView?$skiptoken=x",
"https://graph.microsoft.com:443/v1.0/me/calendars/"
+ percent_encode(secondary_remote_id) + "/calendarView?$skiptoken=x",
exact_prefix + "?$skiptoken=x#fragment",
exact_prefix + "?$skiptoken=x\nInjected: yes",
secondary_next("cross-calendar", "cross"),
exact_prefix + "/subpath?$skiptoken=x",
exact_prefix + "/delta?$skiptoken=x",
exact_prefix + "?$skiptoken=" + std::string(16U * 1024U, 'x')};
for (const std::string& next : unsafe) {
Rig rig{root};
const std::string secondary_id = seed_secondary_calendar(rig);
const CacheSnapshot baseline = rig.cache.snapshot();
const std::string body = view_page(
nlohmann::json::array({live_event("SENSITIVE_UNCOMMITTED_EVENT")}), next);
rig.transport.respond(body);
const std::size_t before = rig.transport.requests.size();
const std::array<std::string_view, 5> sensitive{rig.credentials.access_token,
secondary_remote_id, "SENSITIVE_UNCOMMITTED_EVENT", next, body};
require_throws_redacted(
[&] {
rig.graph.sync_secondary_calendar_window(
rig.credentials, secondary_id, july_window());
},
"unsafe secondary continuation was accepted", sensitive);
require(rig.transport.requests.size() == before + 1,
"unsafe secondary continuation reached a credentialed follow-up");
require(rig.cache.snapshot() == baseline,
"unsafe secondary continuation changed cache state");
}
Rig repeated{root};
const std::string secondary_id = seed_secondary_calendar(repeated);
const CacheSnapshot baseline = repeated.cache.snapshot();
const std::string next = secondary_next(secondary_remote_id, "repeated");
repeated.transport.respond(view_page(nlohmann::json::array(), next));
repeated.transport.respond(view_page(nlohmann::json::array(), next));
const std::size_t before = repeated.transport.requests.size();
const std::array<std::string_view, 3> sensitive{
repeated.credentials.access_token, secondary_remote_id, next};
require_throws_redacted(
[&] {
repeated.graph.sync_secondary_calendar_window(
repeated.credentials, secondary_id, july_window());
},
"repeated secondary continuation was accepted", sensitive);
require(repeated.transport.requests.size() == before + 2,
"repeated secondary continuation triggered another credentialed follow-up");
require(repeated.cache.snapshot() == baseline,
"repeated secondary continuation committed a partial listing");
}
void test_secondary_page_shape_is_strict_and_atomic(const std::filesystem::path& root) {
const std::string next = secondary_next(secondary_remote_id, "valid-next");
const std::string other = secondary_next(secondary_remote_id, "other-link");
const std::vector<std::string> invalid{
view_page(nlohmann::json::array(), std::nullopt, other),
view_page(nlohmann::json::array(), next, other),
R"({"value":[],"value":[]})",
R"({"value":{}})",
view_page(nlohmann::json::array(
{live_event("duplicate-secondary"), live_event("duplicate-secondary")})),
view_page(nlohmann::json::array(
{{{"id", "tombstone-secondary"}, {"@removed", {{"reason", "deleted"}}}}})),
};
for (const std::string& body : invalid) {
Rig rig{root};
const std::string secondary_id = seed_secondary_calendar(rig);
sync_secondary_page(rig, secondary_id,
nlohmann::json::array({live_event("baseline-shape")}));
const CacheSnapshot baseline = rig.cache.snapshot();
rig.transport.respond(body);
const std::array<std::string_view, 4> sensitive{
rig.credentials.access_token, secondary_remote_id, body, "tombstone-secondary"};
require_throws_redacted(
[&] {
rig.graph.sync_secondary_calendar_window(
rig.credentials, secondary_id, july_window());
},
"invalid complete-view page shape was accepted", sensitive);
require(rig.cache.snapshot() == baseline,
"invalid complete-view page changed event, membership, or checkpoint state");
}
}
void test_secondary_omission_and_overlapping_membership(const std::filesystem::path& root) {
{
Rig rig{root};
const std::string secondary_id = seed_secondary_calendar(rig);
sync_secondary_page(rig, secondary_id,
nlohmann::json::array({live_event("omitted-secondary")}));
const CacheSnapshot present = rig.cache.snapshot();
require(present.events.size() == 1 && present.instances.size() == 1,
"secondary complete view did not cache its event");
sync_secondary_page(rig, secondary_id, nlohmann::json::array());
const CacheSnapshot omitted = rig.cache.snapshot();
require(omitted.events.size() == 1 && !omitted.events[0].deleted
&& omitted.instances.empty(),
"completed omission tombstoned the event or retained an orphan instance");
}
{
Rig rig{root};
const std::string secondary_id = seed_secondary_calendar(rig);
const MicrosoftGraphWindow first =
window_from_days(year{2026} / July / 18, year{2026} / July / 21);
const MicrosoftGraphWindow second =
window_from_days(year{2026} / July / 19, year{2026} / July / 22);
const nlohmann::json shared = live_event("shared-secondary",
"2026-07-19T09:00:00.0000000", "2026-07-19T10:00:00.0000000");
sync_secondary_page(rig, secondary_id, nlohmann::json::array({shared}), first);
sync_secondary_page(rig, secondary_id, nlohmann::json::array({shared}), second);
require(rig.cache.snapshot().instances.size() == 1,
"overlapping windows duplicated a shared event instance");
sync_secondary_page(rig, secondary_id, nlohmann::json::array(), first);
require(rig.cache.snapshot().instances.size() == 1,
"omission from one window evicted an instance retained by another window");
sync_secondary_page(rig, secondary_id, nlohmann::json::array(), second);
const CacheSnapshot removed = rig.cache.snapshot();
require(removed.instances.empty() && removed.events.size() == 1
&& !removed.events[0].deleted,
"last membership removal did not evict the instance without tombstoning");
}
}
void test_secondary_cache_collisions_roll_back(const std::filesystem::path& root) {
{
Rig rig{root};
const std::string secondary_id = seed_secondary_calendar(rig);
const std::string target_remote = "target-secondary-identity";
const std::string target_id = local_event_id(secondary_id, target_remote);
const CachedEvent poison{target_id, secondary_id, "different-secondary-remote", "", "",
"", R"({"poison":"identity"})", false};
rig.cache.apply_pull_page(PullPage{{poison}, {},
SyncCheckpoint{secondary_id, "2026-07-18T00:00:00.000000Z",
"2026-07-19T00:00:00.000000Z", "identity-baseline", false}});
const CacheSnapshot baseline = rig.cache.snapshot();
const std::string body =
view_page(nlohmann::json::array({live_event(target_remote)}));
rig.transport.respond(body);
const std::array<std::string_view, 4> sensitive{
rig.credentials.access_token, secondary_remote_id, target_remote, body};
require_throws_redacted(
[&] {
rig.graph.sync_secondary_calendar_window(
rig.credentials, secondary_id, july_window());
},
"secondary event identity collision was accepted", sensitive);
require(rig.cache.snapshot() == baseline,
"secondary identity collision changed snapshot or checkpoint");
}
{
Rig rig{root};
const std::string secondary_id = seed_secondary_calendar(rig);
const std::string target_remote = "target-secondary-instance";
const std::string target_event = local_event_id(secondary_id, target_remote);
const std::string poison_event = local_event_id(secondary_id, "poison-owner");
const CachedEvent poison{poison_event, secondary_id, "poison-owner", "", "", "",
R"({"poison":"instance"})", false};
const auto start = sys_days{year{2026} / July / 18} + 7h;
const CachedEventInstance colliding{local_instance_id(target_event), poison_event,
duration_cast<microseconds>(start.time_since_epoch()).count(),
duration_cast<microseconds>((start + 1h).time_since_epoch()).count(), false,
"Poison", "", "", "UTC"};
rig.cache.apply_pull_page(PullPage{{poison}, {colliding},
SyncCheckpoint{secondary_id, "2026-07-18T00:00:00.000000Z",
"2026-07-19T00:00:00.000000Z", "instance-baseline", false}});
const CacheSnapshot baseline = rig.cache.snapshot();
rig.transport.respond(
view_page(nlohmann::json::array({live_event(target_remote)})));
const std::array<std::string_view, 3> sensitive{
rig.credentials.access_token, secondary_remote_id, target_remote};
require_throws_redacted(
[&] {
rig.graph.sync_secondary_calendar_window(
rig.credentials, secondary_id, july_window());
},
"secondary instance identity collision was accepted", sensitive);
require(rig.cache.snapshot() == baseline,
"secondary instance collision changed snapshot or checkpoint");
}
}
void test_primary_delta_after_secondary_full_sync(const std::filesystem::path& root) {
Rig rig{root};
const std::string secondary_id = seed_secondary_calendar(rig);
sync_secondary_page(rig, secondary_id,
nlohmann::json::array({live_event("secondary-before-primary")}));
const std::string delta =
"https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=after-secondary";
rig.transport.respond(delta_page(
nlohmann::json::array({live_event("primary-after-secondary")}), std::nullopt, delta));
const std::size_t request_index = rig.transport.requests.size();
rig.graph.sync_primary_calendar_window(rig.credentials, july_window());
require(rig.transport.requests[request_index].url.find("/v1.0/me/calendarView/delta?")
!= std::string::npos,
"primary delta did not use its documented route after secondary sync");
require(rig.transport.requests[request_index].url.find("/calendars/") == std::string::npos,
"primary delta reused the secondary calendarView route");
const CacheSnapshot snapshot = rig.cache.snapshot();
require(snapshot.events.size() == 2 && snapshot.instances.size() == 2,
"primary delta after secondary sync lost one provider view");
require(std::ranges::any_of(snapshot.checkpoints,
[&](const SyncCheckpoint& checkpoint) {
return checkpoint.calendar_id
== calendar_with_remote(snapshot, primary_remote_id).id
&& checkpoint.cursor == delta && checkpoint.complete;
}),
"primary delta did not commit its checkpoint after secondary sync");
}
} // namespace
int main() {
@@ -687,6 +1124,13 @@ int main() {
test_resume_after_later_failure(temporary.path());
test_tombstone_preserves_payload(temporary.path());
test_cache_failure_does_not_advance_cursor(temporary.path());
test_secondary_exact_route_and_preconditions(temporary.path());
test_secondary_partial_pages_are_atomic_and_redacted(temporary.path());
test_secondary_continuation_security(temporary.path());
test_secondary_page_shape_is_strict_and_atomic(temporary.path());
test_secondary_omission_and_overlapping_membership(temporary.path());
test_secondary_cache_collisions_roll_back(temporary.path());
test_primary_delta_after_secondary_full_sync(temporary.path());
} catch (const std::exception& error) {
std::cerr << "Microsoft Graph security test failure: " << error.what() << '\n';
return 1;

View File

@@ -2,6 +2,7 @@
#include <openssl/evp.h>
#include <algorithm>
#include <array>
#include <chrono>
#include <cstddef>
@@ -23,6 +24,10 @@ namespace {
using namespace std::chrono;
using nocal::storage::CachedAccount;
using nocal::storage::CachedCalendar;
using nocal::storage::CachedEvent;
using nocal::storage::CachedEventInstance;
using nocal::storage::CompleteWindow;
using nocal::storage::SyncCheckpoint;
using nocal::storage::SyncCache;
using nocal::sync::HttpMethod;
using nocal::sync::HttpRequest;
@@ -170,6 +175,52 @@ void seed_primary(SyncCache& cache, std::string remote = "primary-remote") {
cache.replace_calendars_after_complete_listing("account-local", {&calendar, 1});
}
[[nodiscard]] std::string seed_secondary(
SyncCache& cache, std::string remote = "Secondary /Case?x") {
seed_account(cache);
const std::string local_id = calendar_id(remote);
const CachedCalendar calendar{local_id, "account-local", std::move(remote), "Secondary",
"#445566", false, true, "{\"isDefaultCalendar\":false}"};
cache.replace_calendars_after_complete_listing("account-local", {&calendar, 1});
return local_id;
}
[[nodiscard]] std::string event_object(std::string_view id, std::string_view subject,
std::string_view start = "2026-07-18T09:00:00.1234567Z",
std::string_view end = "2026-07-18T10:00:00.1234567Z") {
return "{\"id\":\"" + std::string(id)
+ "\",\"iCalUId\":\"series-uid\",\"@odata.etag\":\"etag\","
"\"changeKey\":\"change\",\"subject\":\""
+ std::string(subject)
+ "\",\"location\":{\"displayName\":\"Room\"},"
"\"bodyPreview\":\"Preview\",\"isAllDay\":false,\"type\":\"occurrence\","
"\"start\":{\"dateTime\":\""
+ std::string(start)
+ "\",\"timeZone\":\"Ignored\"},\"end\":{\"dateTime\":\""
+ std::string(end) + "\",\"timeZone\":\"Ignored\"}}";
}
[[nodiscard]] std::string view_page(
std::string events, std::optional<std::string> next = std::nullopt) {
std::string body = "{\"value\":[" + std::move(events) + "]";
if (next) {
body += ",\"@odata.nextLink\":\"" + *next + "\"";
}
return body + "}";
}
[[nodiscard]] MicrosoftGraphWindow july_window() {
return {epoch_microseconds(2026y / July / 1), epoch_microseconds(2026y / August / 1)};
}
[[nodiscard]] const CachedEvent& event_by_remote(
const nocal::storage::CacheSnapshot& snapshot, std::string_view remote) {
const auto found = std::find_if(snapshot.events.begin(), snapshot.events.end(),
[&](const CachedEvent& event) { return event.remote_id == remote; });
require(found != snapshot.events.end(), "expected cached event is missing");
return *found;
}
void test_profile_mapping_headers_and_atomic_validation(const TemporaryDirectory& temporary) {
SyncCache cache(temporary.database("profile.db"));
ScriptedTransport transport;
@@ -433,6 +484,340 @@ void test_invalid_inputs_continuations_and_timestamps(const TemporaryDirectory&
"invalid timestamp leaked or committed event data");
}
void test_secondary_route_pagination_and_occurrence_mapping(
const TemporaryDirectory& temporary) {
SyncCache cache(temporary.database("secondary-route.db"));
const std::string local_calendar = seed_secondary(cache);
const std::string route = "https://graph.microsoft.com/v1.0/me/calendars/"
"Secondary%20%2FCase%3Fx/calendarView";
const std::string next = route + "?$skiptoken=page-two";
ScriptedTransport transport;
transport.steps = {{json_response(view_page(event_object("occurrence-one", "First"), next))},
{json_response(view_page(event_object("occurrence-two", "Second",
"2026-07-19T11:00:00Z", "2026-07-19T12:00:00Z")))}};
MicrosoftGraphSync graph(transport, cache);
graph.sync_secondary_calendar_window(credentials(), local_calendar, july_window());
require(transport.requests.size() == 2
&& transport.requests[0].url
== route
+ "?startDateTime=2026-07-01T00%3A00%3A00.000000Z&"
"endDateTime=2026-08-01T00%3A00%3A00.000000Z&$top=1000"
&& transport.requests[1].url == next,
"secondary sync used the wrong encoded calendarView route or pagination URL");
for (const HttpRequest& request : transport.requests) {
require_graph_headers(request);
require(request.url.find("/beta/") == std::string::npos
&& request.url.find("/delta") == std::string::npos,
"secondary sync used beta or a delta route");
}
const auto snapshot = cache.snapshot();
require(snapshot.events.size() == 2 && snapshot.instances.size() == 2,
"two-page secondary view was not committed completely");
const CachedEvent& first = event_by_remote(snapshot, "occurrence-one");
require(first.id
== expected_id("graph-event-", "event", local_calendar, "occurrence-one")
&& first.uid == "series-uid" && !first.deleted,
"secondary recurrence occurrence mapping or stable ID is wrong");
const auto first_instance = std::find_if(snapshot.instances.begin(), snapshot.instances.end(),
[&](const CachedEventInstance& instance) { return instance.event_id == first.id; });
require(first_instance != snapshot.instances.end()
&& first_instance->id == expected_id("graph-instance-", "instance", first.id)
&& first_instance->title == "First",
"secondary occurrence instance mapping is wrong");
require(snapshot.checkpoints.size() == 1 && snapshot.checkpoints[0].calendar_id == local_calendar
&& snapshot.checkpoints[0].window_start == "2026-07-01T00:00:00.000000Z"
&& snapshot.checkpoints[0].window_end == "2026-08-01T00:00:00.000000Z"
&& snapshot.checkpoints[0].cursor == "graph-calendar-view-v1"
&& snapshot.checkpoints[0].complete,
"secondary complete-window checkpoint is wrong");
}
void test_secondary_update_empty_and_moved_out(const TemporaryDirectory& temporary) {
SyncCache cache(temporary.database("secondary-replace.db"));
const std::string calendar = seed_secondary(cache, "secondary-replace");
ScriptedTransport transport;
transport.steps.push_back({json_response(view_page(event_object("event-a", "Old A") + ","
+ event_object("event-b", "Moved B")))});
transport.steps.push_back(
{json_response(view_page(event_object("event-a", "Updated A")))});
transport.steps.push_back({json_response(view_page({}))});
MicrosoftGraphSync graph(transport, cache);
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
auto snapshot = cache.snapshot();
const CachedEvent& updated = event_by_remote(snapshot, "event-a");
const CachedEvent& moved = event_by_remote(snapshot, "event-b");
require(!updated.deleted && !moved.deleted,
"secondary update or moved-out omission was treated as a tombstone");
require(std::ranges::any_of(snapshot.instances, [&](const CachedEventInstance& instance) {
return instance.event_id == updated.id && instance.title == "Updated A";
}), "secondary event update did not replace its instance");
require(std::ranges::none_of(snapshot.instances, [&](const CachedEventInstance& instance) {
return instance.event_id == moved.id;
}), "moved-out event retained an instance in the completed window");
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
snapshot = cache.snapshot();
require(snapshot.instances.empty(), "empty secondary window did not evict membership");
require(std::ranges::all_of(snapshot.events,
[](const CachedEvent& event) { return !event.deleted; }),
"empty window marked omitted secondary events deleted");
}
void test_secondary_overlapping_window_retention(const TemporaryDirectory& temporary) {
SyncCache cache(temporary.database("secondary-overlap.db"));
const std::string calendar = seed_secondary(cache, "secondary-overlap");
ScriptedTransport transport;
transport.steps.push_back(
{json_response(view_page(event_object("shared-event", "Shared")))});
transport.steps.push_back({json_response(view_page({}))});
MicrosoftGraphSync graph(transport, cache);
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
const auto seeded = cache.snapshot();
require(seeded.events.size() == 1 && seeded.instances.size() == 1,
"overlap fixture was not cached");
CompleteWindow overlap{{seeded.events[0]}, {seeded.instances[0]},
SyncCheckpoint{calendar, "2026-07-15T00:00:00.000000Z",
"2026-08-15T00:00:00.000000Z", "graph-calendar-view-v1", true}};
cache.replace_complete_window(overlap);
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
const auto retained = cache.snapshot();
require(retained.instances.size() == 1
&& retained.instances[0].event_id == seeded.events[0].id,
"empty overlapping window removed an instance still referenced by another window");
}
void test_secondary_paged_failures_are_atomic_and_redacted(
const TemporaryDirectory& temporary) {
SyncCache cache(temporary.database("secondary-failure.db"));
const std::string calendar = seed_secondary(cache, "secondary-failure");
ScriptedTransport initial;
initial.steps.push_back(
{json_response(view_page(event_object("existing-event", "Existing")))});
MicrosoftGraphSync initial_graph(initial, cache);
initial_graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
const auto baseline = cache.snapshot();
const std::string route =
"https://graph.microsoft.com/v1.0/me/calendars/secondary-failure/calendarView";
const std::string next = route + "?$skiptoken=SENSITIVE_CURSOR";
ScriptedTransport transport_failure;
transport_failure.steps = {
{json_response(view_page(event_object("SENSITIVE_REMOTE_EVENT", "New"), next))},
{{}, true}};
MicrosoftGraphSync transport_graph(transport_failure, cache);
const std::string transport_message = expect_graph_error([&] {
transport_graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
});
require(cache.snapshot() == baseline, "later secondary transport failure changed prior window");
require(transport_message.find("SENSITIVE") == std::string::npos
&& transport_message.find("ACCESS_TOKEN_SECRET") == std::string::npos
&& transport_message.find("secondary-failure") == std::string::npos,
"secondary transport failure leaked token, cursor, remote ID, or calendar ID");
ScriptedTransport malformed;
malformed.steps = {
{json_response(view_page(event_object("new-first-page", "New"), next))},
{json_response("{\"value\":\"SENSITIVE_MALFORMED_BODY\"")}};
MicrosoftGraphSync malformed_graph(malformed, cache);
const std::string malformed_message = expect_graph_error([&] {
malformed_graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
});
require(cache.snapshot() == baseline, "later malformed secondary page changed prior window");
require(malformed_message.find("SENSITIVE") == std::string::npos,
"secondary malformed response leaked provider body");
ScriptedTransport duplicate;
duplicate.steps = {
{json_response(view_page(event_object("duplicate-event", "First"), next))},
{json_response(view_page(event_object("duplicate-event", "Second")))}};
MicrosoftGraphSync duplicate_graph(duplicate, cache);
expect_graph_error([&] {
duplicate_graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
});
require(cache.snapshot() == baseline,
"duplicate event across secondary pages changed prior window");
ScriptedTransport rejected;
rejected.steps.push_back({json_response("SENSITIVE_NON_2XX_BODY", 429)});
MicrosoftGraphSync rejected_graph(rejected, cache);
const std::string rejected_message = expect_graph_error([&] {
rejected_graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
});
require(cache.snapshot() == baseline, "secondary non-2xx response changed prior window");
require(rejected_message.find("SENSITIVE") == std::string::npos,
"secondary non-2xx error leaked its response body");
}
void test_secondary_preconditions_do_not_use_network(const TemporaryDirectory& temporary) {
const auto require_no_network_failure = [&](SyncCache& cache,
MicrosoftGraphCredentials operation_credentials,
std::string calendar_id_value,
std::string_view message) {
ScriptedTransport transport;
MicrosoftGraphSync graph(transport, cache);
expect_graph_error([&] {
graph.sync_secondary_calendar_window(
operation_credentials, std::move(calendar_id_value), july_window());
});
require(transport.requests.empty(), message);
};
SyncCache primary(temporary.database("secondary-primary-reject.db"));
seed_primary(primary);
require_no_network_failure(primary, credentials(), calendar_id("primary-remote"),
"primary calendar secondary sync reached network");
SyncCache wrong_account(temporary.database("secondary-account-reject.db"));
const std::string wrong_account_calendar = seed_secondary(wrong_account, "wrong-account");
require_no_network_failure(wrong_account, {"different-account", "ACCESS_TOKEN_SECRET"},
wrong_account_calendar, "wrong-account secondary sync reached network");
SyncCache inactive(temporary.database("secondary-inactive-reject.db"));
const std::string inactive_id = seed_secondary(inactive, "inactive");
inactive.replace_calendars_after_complete_listing("account-local", {});
require_no_network_failure(inactive, credentials(), inactive_id,
"inactive secondary sync reached network");
SyncCache missing(temporary.database("secondary-missing-reject.db"));
seed_account(missing);
require_no_network_failure(missing, credentials(), "missing-calendar",
"missing secondary sync reached network");
SyncCache malformed(temporary.database("secondary-malformed-reject.db"));
seed_account(malformed);
const CachedCalendar malformed_calendar{calendar_id("malformed"), "account-local",
"malformed", "Malformed", "", false, true, "{"};
malformed.replace_calendars_after_complete_listing(
"account-local", {&malformed_calendar, 1});
require_no_network_failure(malformed, credentials(), malformed_calendar.id,
"malformed-calendar secondary sync reached network");
SyncCache invalid_window(temporary.database("secondary-window-reject.db"));
const std::string valid_secondary = seed_secondary(invalid_window, "invalid-window");
ScriptedTransport transport;
MicrosoftGraphSync graph(transport, invalid_window);
expect_graph_error([&] {
graph.sync_secondary_calendar_window(credentials(), valid_secondary,
{july_window().start_epoch_microseconds, july_window().start_epoch_microseconds});
});
require(transport.requests.empty(), "invalid secondary window reached network");
}
void test_secondary_continuation_security(const TemporaryDirectory& temporary) {
const std::string route =
"https://graph.microsoft.com/v1.0/me/calendars/secondary-links/calendarView";
const std::vector<std::string> unsafe{
"http://graph.microsoft.com/v1.0/me/calendars/secondary-links/calendarView?$skip=x",
"https://evil.example/v1.0/me/calendars/secondary-links/calendarView?$skip=x",
"https://user@graph.microsoft.com/v1.0/me/calendars/secondary-links/calendarView?$skip=x",
"https://graph.microsoft.com:443/v1.0/me/calendars/secondary-links/calendarView?$skip=x",
"https://graph.microsoft.com/v1.0/me/calendars/other/calendarView?$skip=x",
route + "?$skip=x#fragment", route + "?$skip=x\\nInjected:true",
route + "?" + std::string(16U * 1024U, 'x')};
int sequence = 0;
for (const std::string& next : unsafe) {
SyncCache cache(temporary.database(
"secondary-link-" + std::to_string(sequence++) + ".db"));
const std::string calendar = seed_secondary(cache, "secondary-links");
const auto baseline = cache.snapshot();
ScriptedTransport transport;
transport.steps.push_back({json_response(
view_page(event_object("SENSITIVE_REMOTE_ID", "Sensitive"), next))});
MicrosoftGraphSync graph(transport, cache);
const std::string message = expect_graph_error([&] {
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
});
require(transport.requests.size() == 1,
"unsafe secondary continuation received credentials");
require(cache.snapshot() == baseline, "unsafe secondary continuation changed cache");
require(message.find("SENSITIVE") == std::string::npos
&& message.find("ACCESS_TOKEN_SECRET") == std::string::npos,
"unsafe secondary continuation error leaked secrets");
}
SyncCache repeated_cache(temporary.database("secondary-link-repeated.db"));
const std::string repeated_calendar = seed_secondary(repeated_cache, "secondary-links");
const auto repeated_baseline = repeated_cache.snapshot();
const std::string repeated = route + "?$skiptoken=repeated";
ScriptedTransport repeated_transport;
repeated_transport.steps = {
{json_response(view_page({}, repeated))}, {json_response(view_page({}, repeated))}};
MicrosoftGraphSync repeated_graph(repeated_transport, repeated_cache);
expect_graph_error([&] {
repeated_graph.sync_secondary_calendar_window(
credentials(), repeated_calendar, july_window());
});
require(repeated_transport.requests.size() == 2,
"repeated secondary continuation triggered another credentialed request");
require(repeated_cache.snapshot() == repeated_baseline,
"repeated secondary continuation changed cache");
SyncCache delta_cache(temporary.database("secondary-delta-reject.db"));
const std::string delta_calendar = seed_secondary(delta_cache, "secondary-links");
const auto delta_baseline = delta_cache.snapshot();
ScriptedTransport delta_transport;
delta_transport.steps.push_back({json_response(
"{\"value\":[],\"@odata.deltaLink\":\"" + route + "?$delta=x\"}")});
MicrosoftGraphSync delta_graph(delta_transport, delta_cache);
expect_graph_error([&] {
delta_graph.sync_secondary_calendar_window(credentials(), delta_calendar, july_window());
});
require(delta_cache.snapshot() == delta_baseline,
"secondary calendarView accepted or committed a deltaLink");
}
void test_secondary_aggregate_response_bound(const TemporaryDirectory& temporary) {
SyncCache cache(temporary.database("secondary-aggregate.db"));
const std::string calendar = seed_secondary(cache, "secondary-aggregate");
const auto baseline = cache.snapshot();
const std::string route =
"https://graph.microsoft.com/v1.0/me/calendars/secondary-aggregate/calendarView";
ScriptedTransport transport;
const std::string padding(7U * 1024U * 1024U, 'x');
for (int page = 0; page < 10; ++page) {
const std::string next = route + "?$skiptoken=aggregate-" + std::to_string(page + 1);
transport.steps.push_back({json_response("{\"value\":[],\"padding\":\"" + padding
+ "\",\"@odata.nextLink\":\"" + next + "\"}")});
}
MicrosoftGraphSync graph(transport, cache);
expect_graph_error([&] {
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
});
require(transport.requests.size() == 10,
"secondary aggregate response bound fired at the wrong page");
require(cache.snapshot() == baseline,
"aggregate secondary response overflow changed prior cache state");
}
void test_secondary_cache_failure_is_atomic(const TemporaryDirectory& temporary) {
SyncCache cache(temporary.database("secondary-cache-failure.db"));
const std::string calendar = seed_secondary(cache, "secondary-cache-failure");
const std::string target_id =
expected_id("graph-event-", "event", calendar, "target-remote");
CompleteWindow poisoned{{CachedEvent{target_id, calendar, "different-remote", "uid", "", "",
"{\"poisoned\":true}", false}},
{}, SyncCheckpoint{calendar, "2026-07-01T00:00:00.000000Z",
"2026-08-01T00:00:00.000000Z", "graph-calendar-view-v1", true}};
cache.replace_complete_window(poisoned);
const auto baseline = cache.snapshot();
ScriptedTransport transport;
transport.steps.push_back(
{json_response(view_page(event_object("target-remote", "Target")))});
MicrosoftGraphSync graph(transport, cache);
const std::string message = expect_graph_error([&] {
graph.sync_secondary_calendar_window(credentials(), calendar, july_window());
});
require(cache.snapshot() == baseline,
"secondary cache failure partially replaced the complete window");
require(message.find("target-remote") == std::string::npos
&& message.find("ACCESS_TOKEN_SECRET") == std::string::npos,
"secondary cache failure leaked remote ID or token");
}
} // namespace
int main() {
@@ -443,6 +828,14 @@ int main() {
test_delta_mapping_incremental_tombstone_and_routes(temporary);
test_page_resume_and_primary_only_failure(temporary);
test_invalid_inputs_continuations_and_timestamps(temporary);
test_secondary_route_pagination_and_occurrence_mapping(temporary);
test_secondary_update_empty_and_moved_out(temporary);
test_secondary_overlapping_window_retention(temporary);
test_secondary_paged_failures_are_atomic_and_redacted(temporary);
test_secondary_preconditions_do_not_use_network(temporary);
test_secondary_continuation_security(temporary);
test_secondary_aggregate_response_bound(temporary);
test_secondary_cache_failure_is_atomic(temporary);
} catch (const std::exception& error) {
std::cerr << "Microsoft Graph tests failed: " << error.what() << '\n';
return 1;

View File

@@ -30,6 +30,7 @@ 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;
@@ -211,6 +212,13 @@ PullPage page(CachedEvent changed_event = event(), CachedEventInstance changed_i
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);
@@ -249,11 +257,13 @@ void test_fresh_schema_permissions_and_secrets(const std::filesystem::path& root
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"}) {
"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", "sync_state"}) {
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)
@@ -261,6 +271,11 @@ void test_fresh_schema_permissions_and_secrets(const std::filesystem::path& root
> 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 "
@@ -397,6 +412,176 @@ void test_pull_pages_replacement_tombstone_and_windows(const std::filesystem::pa
"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};
@@ -721,6 +906,71 @@ void test_foreign_key_failure_rolls_back(const std::filesystem::path& root) {
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";
{
@@ -743,17 +993,46 @@ void test_migration_failures_are_non_destructive(const std::filesystem::path& ro
"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=2");
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") == 2,
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");
@@ -804,11 +1083,15 @@ int main() {
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) {