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;