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

@@ -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;