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