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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user